summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/js/models
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/models
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/models')
-rw-r--r--addons/mail/static/src/js/models/channel.js442
-rw-r--r--addons/mail/static/src/js/models/dm.js94
-rw-r--r--addons/mail/static/src/js/models/document_thread.js276
-rw-r--r--addons/mail/static/src/js/models/mail_failure.js102
-rw-r--r--addons/mail/static/src/js/models/mail_model.js20
-rw-r--r--addons/mail/static/src/js/models/mailbox.js213
-rw-r--r--addons/mail/static/src/js/models/messages/abstract_message.js409
-rw-r--r--addons/mail/static/src/js/models/messages/message.js780
-rw-r--r--addons/mail/static/src/js/models/thread.js446
-rw-r--r--addons/mail/static/src/js/models/thread_with_cache.js204
10 files changed, 2986 insertions, 0 deletions
diff --git a/addons/mail/static/src/js/models/channel.js b/addons/mail/static/src/js/models/channel.js
new file mode 100644
index 00000000000..e63cab2f907
--- /dev/null
+++ b/addons/mail/static/src/js/models/channel.js
@@ -0,0 +1,442 @@
+odoo.define('mail.model.Channel', function (require) {
+"use strict";
+
+var ThreadWithCache = require('mail.model.ThreadWithCache');
+var mailUtils = require('mail.utils');
+
+var time = require('web.time');
+
+/**
+ * This class represent channels in JS. In this context, the word channel
+ * has the same meaning of channel on the server, meaning that direct messages
+ * (DM) and livechats are also channels.
+ *
+ * Any piece of code in JS that make use of channels must ideally interact with
+ * such objects, instead of direct data from the server.
+ */
+var Channel = ThreadWithCache.extend({
+ /**
+ * @override
+ * @param {string} [data.anonymous_name]
+ * @param {string} data.channel_type
+ * @param {boolean} data.group_based_subscription
+ * @param {boolean} [data.is_minimized=false]
+ * @param {boolean} data.is_moderator whether the current user is
+ * moderator of this channel.
+ * @param {string} [data.last_message_date] date in server-format
+ * @param {boolean} [data.mass_mailing]
+ * @param {integer} [data.message_unread_counter]
+ * @param {string} [data.public] either 'public' or 'private'
+ * @param {string} data.state
+ * @param {string} [data.uuid]
+ * @param {Object} options
+ * @param {boolean} [options.autoswitch=true]
+ * @param {Object[]} commands
+ */
+ init: function (parent, data, options, commands) {
+ var self = this;
+ this._super.apply(this, arguments);
+
+ // If set, autoswitch channel on joining this channel in discuss
+ // the default behaviour is to autoswitch on join.
+ // exception: receiving channel or chat session notifications
+ //
+ this._autoswitch = 'autoswitch' in options ? options.autoswitch : true;
+ this._chat = undefined; // FIXME: could be dropped when livechat and DM are moved out of this class
+ this._commands = undefined;
+ this._detached = data.is_minimized;
+ this._directPartnerID = undefined;
+ this._folded = data.state === 'folded';
+ // if set: hide 'Leave channel' button
+ this._groupBasedSubscription = data.group_based_subscription;
+ this._isModerator = data.is_moderator;
+ this._lastMessageDate = undefined;
+ this._massMailing = data.mass_mailing;
+ // Deferred that is resolved on fetched members of this channel.
+ this._membersDef = undefined;
+ this._moderation = data.is_moderation;
+ // number of messages that are 'needaction', which is equivalent to the
+ // number of messages in this channel that are in inbox.
+ this._needactionCounter = data.message_needaction_counter || 0;
+ this._serverType = data.channel_type;
+ this._status = undefined;
+ this._throttleFetchSeen = _.throttle(this._fetchSeen.bind(this), 3000);
+ // unique identifier for this channel, which is required for some rpc
+ this._uuid = data.uuid;
+
+ // * list of commands available for this channel
+ this._commands = _.filter(commands, function (command) {
+ return !command.channel_types ||
+ _.contains(command.channel_types, self._serverType);
+ });
+ if (this._type === 'channel') {
+ this._type = data.public !== 'private' ? 'public' : 'private';
+ }
+ if ('anonymous_name' in data) {
+ this._name = data.anonymous_name;
+ }
+ if (data.last_message_date) {
+ this._lastMessageDate = moment(time.str_to_datetime(data.last_message_date));
+ }
+ this._chat = !this.getType().match(/^(public|private)$/);
+ if (data.message_unread_counter) {
+ this._unreadCounter = data.message_unread_counter;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Close the channel
+ *
+ * This operation is executed server-side and the chat window will be
+ * folded in all potential tabs from all browsers.
+ *
+ * @override
+ */
+ close: function () {
+ this._super.apply(this, arguments);
+ this._rpc({
+ model: 'mail.channel',
+ method: 'channel_fold',
+ kwargs: { uuid: this.getUUID(), state: 'closed' },
+ }, { shadow: true });
+ },
+ /**
+ * Decrement the needaction counter of the channel
+ * Floor value at 0.
+ *
+ * @param {integer} [num=1] the amount to decrement at most
+ */
+ decrementNeedactionCounter: function (num) {
+ num = _.isNumber(num) ? num : 1;
+ this._needactionCounter = Math.max(this._needactionCounter - 1, 0);
+ },
+ /**
+ * Open the chat window for this channel
+ * (in all potential tabs from all browsers)
+ *
+ * @pverride
+ */
+ detach: function () {
+ this._super.apply(this, arguments);
+ this._rpc({
+ model: 'mail.channel',
+ method: 'channel_minimize',
+ args: [this.getUUID(), true],
+ }, {
+ shadow: true,
+ });
+ },
+ /**
+ * Force fetch members of the channel
+ *
+ * @returns {$.Promise<Object[]>} resolved with list of channel listeners
+ */
+ forceFetchMembers: function () {
+ this._membersDef = undefined;
+ return this.getMentionPartnerSuggestions();
+ },
+ /**
+ * Folds/Minimize the channel
+ * (in all potential tabs from all browsers)
+ *
+ * @override
+ * @param {boolean} folded
+ */
+ fold: function (folded) {
+ this._super.apply(this, arguments);
+ var args = {
+ uuid: this.getUUID(),
+ };
+ if (_.isBoolean(folded)) {
+ args.state = folded ? 'folded' : 'open';
+ }
+ this._rpc({
+ model: 'mail.channel',
+ method: 'channel_fold',
+ kwargs: args,
+ }, {shadow: true});
+ },
+ /**
+ * Show the list of available commands on this channel (e.g. '/help')
+ *
+ * @override
+ * @returns {Array} list of commands
+ */
+ getCommands: function () {
+ return this._commands;
+ },
+ /**
+ * @returns {mail.model.Message|undefined} last message of channel, if any
+ */
+ getLastMessage: function () {
+ return _.last(this._cache['[]'].messages);
+ },
+ /**
+ * Get listeners of a channel
+ *
+ * @returns {$.Promise<Object[]>} resolved with list of channel listeners
+ */
+ getMentionPartnerSuggestions: function () {
+ if (!this._membersDef) {
+ this._membersDef = this._rpc({
+ model: 'mail.channel',
+ method: 'channel_fetch_listeners',
+ args: [this.getUUID()],
+ }, {
+ shadow: true
+ })
+ .then(function (members) {
+ return members;
+ });
+ }
+ return this._membersDef;
+ },
+ /**
+ * @returns {integer}
+ */
+ getNeedactionCounter: function () {
+ return this._needactionCounter;
+ },
+ /**
+ * @override
+ */
+ getPreview: function () {
+ var result = this._super.apply(this, arguments);
+ if (!this.isChat()) {
+ result.imageSRC = '/web/image/mail.channel/' + this.getID() + '/image_small';
+ }
+ var lastMessage = this.getLastMessage();
+ return _.extend(result, {
+ author: lastMessage ? lastMessage.getDisplayedAuthor() : '',
+ body: lastMessage ? mailUtils.parseAndTransform(lastMessage.getBody(), mailUtils.inline) : '',
+ date: lastMessage ? lastMessage.getDate() : moment(),
+ isAuthor: this.hasMessages() && this.getLastMessage().isAuthor(),
+ });
+ },
+ /**
+ * Returns the title to display in thread window's headers.
+ * For channels, the title is prefixed with "#".
+ *
+ * @override
+ * @returns {string|Object} the name of the thread by default (see getName)
+ */
+ getTitle: function () {
+ return "#" + this._super.apply(this, arguments);
+ },
+ /**
+ * Get the UUID of the channel.
+ * This is a string that uniquely links this channel to a server channel.
+ *
+ * @returns {string} uuid of this channel
+ */
+ getUUID: function () {
+ return this._uuid;
+ },
+ /**
+ * Increment the needaction counter of this channel by 1 unit
+ */
+ incrementNeedactionCounter: function () {
+ this._needactionCounter++;
+ },
+ /**
+ * States whether the channel should be auto-selected on creation
+ *
+ * Note that this is not of the responsibility of the channel
+ * (see mail.model.Thread for more information)
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isAutoswitch: function () {
+ return this._autoswitch;
+ },
+ /**
+ * All instances of this class are chanels
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isChannel: function () {
+ return true;
+ },
+ /**
+ * States whether this channel is a chat or not.
+ * These types of channels are chat:
+ * - direct messages (DM)
+ * - livechat
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isChat: function () {
+ return this._chat;
+ },
+ /**
+ * States whether the channel auto-subscribes some users in a group
+ *
+ * @returns {boolean}
+ */
+ isGroupBasedSubscription: function () {
+ return this._groupBasedSubscription;
+ },
+ /**
+ * States whether the channel is moderated or not.
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isModerated: function () {
+ return this._moderation;
+ },
+ /**
+ * States whether the current user is moderator of this channel.
+ *
+ * @returns {boolean}
+ */
+ isModerator: function () {
+ return this._isModerator;
+ },
+ /**
+ * Marks this channel as read.
+ * The last seen message will be the last message.
+ * Resolved with the last seen message, only for non-mailbox channels
+ *
+ * @override
+ * @returns {$.Promise<integer|undefined>} resolved with last message ID
+ * seen in the channel, and when the channel has been marked as seen on
+ * the server.
+ */
+ markAsRead: function () {
+ if (this._unreadCounter > 0) {
+ this.resetUnreadCounter();
+ return this._throttleFetchSeen();
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Prepare and send a message to the server on this channel
+ *
+ * @override
+ * @param {Object} data data related to the new message
+ * @returns {$.Promise<Object>} resolved when the message has been sent to
+ * the server, with the object message that has been sent to the server.
+ */
+ postMessage: function (data) {
+ var self = this;
+ return this._super.apply(this, arguments).then(function (messageData) {
+ _.extend(messageData, {
+ message_type: 'comment',
+ subtype: 'mail.mt_comment',
+ command: data.command,
+ });
+ return self._rpc({
+ model: 'mail.channel',
+ method: data.command ? 'execute_command' : 'message_post',
+ args: [self._id],
+ kwargs: messageData,
+ }).then(function () {
+ return messageData;
+ });
+ });
+ },
+ /**
+ * Unsubscribes from channel
+ *
+ * @returns {$.Promise} resolve when unsubscribed
+ */
+ unsubscribe: function () {
+ if (_.contains(['public', 'private'], this.getType())) {
+ // unfollow channel
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'action_unfollow',
+ args: [[this._id]],
+ });
+ } else {
+ // unpin livechat
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_pin',
+ args: [this.getUUID(), false],
+ });
+ }
+ },
+ /**
+ * Updates the internal state of the channel, and reflects the changes in
+ * the UI.
+ *
+ * Called by {mail.Manager.Notification} on receiving a channel window
+ * notification. A notification is received after a client-side action
+ * that changes the state of the channel window visually, so that cross tab
+ * channels are updated similarly.
+ *
+ * TODO: This method should be dropped if the state of windows are
+ * completely handled client-side, which should be the case in the near
+ * future.
+ *
+ * @param {Object} params
+ * @param {boolean} params.detached
+ * @param {boolean} params.folded
+ */
+ updateWindowState: function (params) {
+ this._folded = 'folded' in params ? params.folded : this._folded;
+ this._detached = 'detached' in params ? params.detached : this._detached;
+ this._warnUpdatedWindowState();
+ },
+ /**
+ * Reset the needaction counter to 0.
+ */
+ resetNeedactionCounter: function () {
+ this._needactionCounter = 0;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {$.Promise<integer>} resolved with ID of last seen message
+ */
+ _fetchSeen: function () {
+ var self = this;
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_seen',
+ args: [[this._id]],
+ }, {
+ shadow: true
+ }).then(function (lastSeenMessageID) {
+ self._lastSeenMessageID = lastSeenMessageID;
+ return lastSeenMessageID;
+ });
+ },
+ /**
+ * Get the domain to fetch all the messages in the current channel
+ *
+ * Note that with moderation, even though some messages are not really
+ * linked to a channel ('channel_ids'), we should nonetheless display them.
+ * These messages are fetched from their associated document
+ * ('mail.channel' with provided channel ID), and messages that are pending
+ * moderation.
+ *
+ * @override
+ * @private
+ * @returns {Array}
+ */
+ _getThreadDomain: function () {
+ return ['|', '&', '&',
+ ['model', '=', 'mail.channel'],
+ ['res_id', 'in', [this._id]],
+ ['need_moderation', '=', true],
+ ['channel_ids', 'in', [this._id]]];
+ },
+});
+
+return Channel;
+
+});
diff --git a/addons/mail/static/src/js/models/dm.js b/addons/mail/static/src/js/models/dm.js
new file mode 100644
index 00000000000..cd08dd7526d
--- /dev/null
+++ b/addons/mail/static/src/js/models/dm.js
@@ -0,0 +1,94 @@
+odoo.define('mail.model.DM', function (require) {
+"use strict";
+
+var Channel = require('mail.model.Channel');
+
+/**
+ *
+ * Any piece of code in JS that make use of DMs must ideally interact with
+ * such objects, instead of direct data from the server.
+ */
+var DM = Channel.extend({
+ /**
+ * @override
+ * @param {Object} data
+ * @param {Object} data.direct_partner
+ * @param {integer} data.direct_partner[0].id
+ * @param {string} data.direct_partner[0].im_status
+ * @param {string} data.direct_partner[0].name
+ */
+ init: function (parent, data) {
+ this._super.apply(this, arguments);
+
+ this._type = 'dm';
+ this._name = data.direct_partner[0].name;
+ this._directPartnerID = data.direct_partner[0].id;
+ this._status = data.direct_partner[0].im_status;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the direct partner ID linked to the DM, i.e. the partner ID of the
+ * user at the other end of the DM conversation. All DM do have a direct
+ * partner iD.
+ *
+ * @returns {integer}
+ */
+ getDirectPartnerID: function () {
+ return this._directPartnerID;
+ },
+ /**
+ * @override
+ */
+ getPreview: function () {
+ var result = this._super.apply(this, arguments);
+ result.imageSRC = '/web/image/res.partner/' + this.getDirectPartnerID() + '/image_small';
+ return result;
+ },
+ /**
+ * @returns {string}
+ */
+ getStatus: function () {
+ return this._status;
+ },
+ /**
+ * DM are chat
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isChat: function () {
+ return true;
+ },
+ /**
+ * @param {string} newStatus
+ */
+ setStatus: function (newStatus) {
+ this._status = newStatus;
+ },
+ /**
+ * Unpin this DM
+ *
+ * @override
+ * @return {$.Promise} resolve when unpinned
+ */
+ unsubscribe: function () {
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_pin',
+ args: [this.getUUID(), false],
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+});
+
+return DM;
+
+});
diff --git a/addons/mail/static/src/js/models/document_thread.js b/addons/mail/static/src/js/models/document_thread.js
new file mode 100644
index 00000000000..b9671107d9d
--- /dev/null
+++ b/addons/mail/static/src/js/models/document_thread.js
@@ -0,0 +1,276 @@
+odoo.define('mail.model.DocumentThread', function (require) {
+"use strict";
+
+var Thread = require('mail.model.Thread');
+
+var session = require('web.session');
+
+/**
+ * These threads represent threads that are directly attached to documents.
+ * It is sometimes called the "chatter", but technically this is just the
+ * part of the chatter containing the thread, as the chatter also contains
+ * the activity and listener fields.
+ *
+ * Note that there are still some hacks in the handling of document threads,
+ * due to document threads are instantiated from fetched messages, either from
+ * mail.ThreadField or by fetching messages in Inbox.
+ *
+ * Also, because messages are not sent on the longpoll bus for this kind of
+ * threads, it stores the list of message IDs that it contains, which should
+ * be up-to-date whenever someone wants to get the messages in the document
+ * thread.
+ */
+var DocumentThread = Thread.extend({
+
+ /**
+ * @override
+ * @param {string} [data.messagesIDs] the list of message ids linked
+ * to the document (if not given, they will be fetched before fetching the
+ * messages)
+ * @param {string} data.model
+ * @param {integer} data.resID
+ */
+ init: function (parent, data) {
+ data.id = data.resModel + '_' + data.resID;
+ data.type = 'document_thread';
+
+ this._super.apply(this, arguments);
+
+ // ID of the related document
+ this._documentID = data.resID;
+ // model of the related document
+ this._documentModel = data.resModel;
+ // used to handle history of messages
+ this._messageIDs = data.messageIDs || [];
+ // list of loaded messages
+ this._messages = [];
+
+ // if the messageIDs haven't been given, fetch them before fetching the
+ // messages
+ this._mustFetchMessageIDs = !_.isArray(data.messageIDs);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add this message to this document thread.
+ *
+ * @override
+ * @param {mail.model.Message} message
+ */
+ addMessage: function (message) {
+ // update internal list of messages
+ this._messages.push(message);
+ this._messages = _.sortBy(this._messages, function (msg) {
+ return msg.getID();
+ });
+ // update message ids associated to this document thread
+ if (!_.contains(this._messageIDs, message.getID())) {
+ this._messageIDs.push(message.getID());
+ }
+ },
+ /**
+ * Get the model name of the document that is linked to this document thread
+ *
+ * @returns {string}
+ */
+ getDocumentModel: function () {
+ return this._documentModel;
+ },
+ /**
+ * Get the ID of the document that is linked to this document thread
+ *
+ * @returns {integer}
+ */
+ getDocumentID: function () {
+ return this._documentID;
+ },
+ /**
+ * Get he list of message IDs that this document threads contain.
+ *
+ * @returns {integer[]}
+ */
+ getMessageIDs: function () {
+ return this._messageIDs;
+ },
+ /**
+ *
+ * Get messages of the document thread
+ *
+ * @override
+ * @param {Object} options
+ * @param {boolean} [options.forceFetch] if true, fetch anyway, as user
+ * clicked on 'load more'.
+ * @returns {$.Promise<mail.model.Message[]>}
+ */
+ getMessages: function (options) {
+ var self = this;
+ return this._fetchMessages(options).then(function () {
+ self.call('mail_service', 'markMessagesAsRead', self._messageIDs);
+ return self._messages;
+ });
+ },
+ /**
+ * States whether the thread is linked to a document
+ * Document thread's are always linked to a document.
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isLinkedToDocument: function () {
+ return true;
+ },
+ /**
+ * Overrides this method so that all the messages of this document thread
+ * are marked as read on the server.
+ *
+ * @override
+ * @returns {$.Promise} resolved when messages have been marked as read on
+ * the server.
+ */
+ markAsRead: function () {
+ this._super.apply(this, arguments);
+ return this.call('mail_service', 'markMessagesAsRead', this._messageIDs);
+ },
+ /**
+ * Post message for document thread
+ *
+ * @override
+ * @param {Object} data data related to the new message
+ * @return {$.Promise<Object>} resolved when the message has been sent to
+ * the server, with the object message that has been sent to the server.
+ */
+ postMessage: function (data) {
+ var self = this;
+ var resModel = this.getDocumentModel();
+ var resID = this.getDocumentID();
+ return this._super.apply(this, arguments)
+ .then(function (messageData) {
+ _.extend(messageData, {
+ context: data.context,
+ message_type: data.message_type,
+ subtype: data.subtype,
+ subtype_id: data.subtype_id,
+ });
+ return self._rpc({
+ model: resModel,
+ method: 'message_post',
+ args: [resID],
+ kwargs: messageData,
+ })
+ .then(function (messageID) {
+ return self._rpc({
+ model: 'mail.message',
+ method: 'message_format',
+ args: [[messageID]],
+ })
+ .then(function (messages) {
+ messages[0].model = resModel;
+ messages[0].res_id = resID;
+ self.call('mail_service', 'addMessage', messages[0]);
+ return messages[0];
+ });
+ });
+ });
+ },
+ /**
+ * Set list of message IDs of this document thread
+ *
+ * Useful in order to handle message history of the document thread,
+ * in particular to fetch messages when necessary and/or display 'load more'.
+ *
+ * @param {integer[]} messageIDs
+ */
+ setMessageIDs: function (messageIDs) {
+ this._mustFetchMessageIDs = false;
+ this._messageIDs = messageIDs;
+ },
+ /**
+ * Set the name of this document thread
+ *
+ * This is useful if the name of the document related to the document thread
+ * has changed
+ *
+ * @param {string} newName
+ */
+ setName: function (newName) {
+ this._name = newName;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get most up to date messageIDs
+ *
+ * @private
+ * @returns {$.Promise} resolved when message IDs have been fetched and set
+ * in the model
+ */
+ _fetchMessageIDs: function () {
+ var self = this;
+ var resID = this.getDocumentID();
+ if (!resID || !this._mustFetchMessageIDs) {
+ return $.when();
+ }
+ return this._rpc({
+ model: this.getDocumentModel(),
+ method: 'read',
+ args: [[resID], ['message_ids']],
+ }).then(function (result) {
+ self.setMessageIDs(result[0].message_ids);
+ });
+ },
+ /**
+ * @override
+ * @private
+ * @param {Object} options
+ * @param {boolean} [options.forceFetch]
+ * @returns {$.Promise} resolved when messages have been fetched + document
+ * thread has updated messages
+ */
+ _fetchMessages: function (options) {
+ var self = this;
+ return this._fetchMessageIDs().then(function () {
+ var messageIDs = self._messageIDs;
+ var loadedMessages = _.filter(self._messages, function (message) {
+ return _.contains(messageIDs, message.getID());
+ });
+ var loadedMessageIDs = _.map(loadedMessages, function (message) {
+ return message.getID();
+ });
+
+ options = options || {};
+ var shouldFetch = _.difference(
+ messageIDs.slice(0, self._FETCH_LIMIT),
+ loadedMessageIDs
+ ).length > 0;
+ if (options.forceFetch || shouldFetch) {
+ var idsToLoad = _.difference(messageIDs, loadedMessageIDs)
+ .slice(0, self._FETCH_LIMIT);
+ return self._rpc({
+ model: 'mail.message',
+ method: 'message_format',
+ args: [idsToLoad],
+ context: session.user_context,
+ })
+ .then(function (messagesData) {
+ _.each(messagesData, function (messageData) {
+ self.call('mail_service', 'addMessage', messageData, { silent: true });
+ });
+ });
+ } else {
+ return $.when();
+ }
+
+ });
+ },
+
+});
+
+return DocumentThread;
+
+});
diff --git a/addons/mail/static/src/js/models/mail_failure.js b/addons/mail/static/src/js/models/mail_failure.js
new file mode 100644
index 00000000000..fe6241ff032
--- /dev/null
+++ b/addons/mail/static/src/js/models/mail_failure.js
@@ -0,0 +1,102 @@
+odoo.define('mail.model.MailFailure', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var core = require('web.core');
+var Mixins = require('web.mixins');
+var ServicesMixin = require('web.ServicesMixin');
+var time = require('web.time');
+
+var _t = core._t;
+
+var MailFailure = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
+ /**
+ * @param {mail.Manager} parent
+ * @param {Object} data
+ * @param {string} data.last_message_date
+ * @param {integer} data.message_id
+ * @param {string} data.model
+ * @param {string} data.model_name
+ * @param {string} data.module_icon
+ * @param {Array} data.notifications
+ * @param {string} data.record_name
+ * @param {integer} data.res_id
+ * @param {string} data.uuid
+ */
+ init: function (parent, data) {
+ Mixins.EventDispatcherMixin.init.call(this, arguments);
+ this.setParent(parent);
+
+ this._documentID = data.res_id;
+ this._documentModel = data.model;
+ this._lastMessageDate = moment(); // by default: current datetime
+ this._messageID = data.message_id;
+ this._modelName = data.model_name;
+ this._moduleIcon = data.module_icon;
+ this._notifications = data.notifications;
+ this._recordName = data.record_name;
+ this._uuid = data.uuid;
+
+ if (data.last_message_date) {
+ this._lastMessageDate = moment(time.str_to_datetime(data.last_message_date));
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the document model that this mail failure is linked to.
+ *
+ * @returns {string}
+ */
+ getDocumentModel: function () {
+ return this._documentModel;
+ },
+ /**
+ * Get the document ID that this mail failure is linked to.
+ *
+ * @returns {integer}
+ */
+ getDocumentID: function () {
+ return this._documentID;
+ },
+ /**
+ * Get the ID of the message that this mail failure is related to.
+ *
+ * @returns {integer}
+ */
+ getMessageID: function () {
+ return this._messageID;
+ },
+ /**
+ * Get a valid object for the 'mail.preview' template
+ *
+ * @returns {Object}
+ */
+ getPreview: function () {
+ var preview = {
+ body: _t("An error occured when sending an email"),
+ date: this._lastMessageDate,
+ documentID: this._documentID,
+ documentModel: this._documentModel,
+ id: 'mail_failure',
+ imageSRC: this._moduleIcon,
+ title: this._recordName,
+ };
+ return preview;
+ },
+ /**
+ * Tell whether the mail failure comes from a message in a document thread.
+ *
+ * @returns {boolean}
+ */
+ isLinkedToDocumentThread: function () {
+ return !!(this._documentModel !== 'mail.channel' && this._documentID);
+ },
+});
+
+return MailFailure;
+
+});
diff --git a/addons/mail/static/src/js/models/mail_model.js b/addons/mail/static/src/js/models/mail_model.js
new file mode 100644
index 00000000000..8f081eb3464
--- /dev/null
+++ b/addons/mail/static/src/js/models/mail_model.js
@@ -0,0 +1,20 @@
+odoo.define('mail.Model', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var Mixins = require('web.mixins');
+var ServicesMixin = require('web.ServicesMixin');
+
+var MailModel = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
+ /**
+ * @param {mail.Manager} parent
+ */
+ init: function (parent) {
+ Mixins.EventDispatcherMixin.init.call(this, arguments);
+ this.setParent(parent);
+ },
+});
+
+return MailModel;
+
+});
diff --git a/addons/mail/static/src/js/models/mailbox.js b/addons/mail/static/src/js/models/mailbox.js
new file mode 100644
index 00000000000..bcb5ae609b7
--- /dev/null
+++ b/addons/mail/static/src/js/models/mailbox.js
@@ -0,0 +1,213 @@
+odoo.define('mail.model.Mailbox', function (require) {
+"use strict";
+
+var ThreadWithCache = require('mail.model.ThreadWithCache');
+
+var core = require('web.core');
+
+var _t = core._t;
+
+/**
+ * Mailboxes, called "static channels" in earlier version of Odoo, are threads
+ * that are not represented on the server. We can see them as "threads that are
+ * not conversations". For instance, the well-known "Inbox" folder contains a
+ * list of messages, but the inbox does not represent a conversation: Inbox is
+ * modeled as a mailbox.
+ */
+var Mailbox = ThreadWithCache.extend({
+
+ /**
+ * @override
+ * @param {Object} data
+ * @param {string} data.id the ID of the mailbox, without the 'mailbox_'
+ * prefix.
+ * @param {integer} [data.mailboxCounter=0] the initial mailbox counter of
+ * this mailbox.
+ */
+ init: function (parent, data) {
+ // ID or mailboxes are always prefixed with 'mailbox_'
+ data.id = 'mailbox_' + data.id;
+ data.type = 'mailbox';
+ this._super.apply(this, arguments);
+ this._mailboxCounter = data.mailboxCounter || 0;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Decrement the counter of this mailbox. It floors to 0.
+ *
+ * @param {integer} [num=1] how many, at most, the counter is decremented
+ */
+ decrementMailboxCounter: function (num) {
+ num = _.isNumber(num) ? num : 1;
+ this._mailboxCounter = Math.max(this._mailboxCounter - num, 0);
+ },
+ /**
+ * Get the local messages of the mailbox (by local messages, we mean
+ * messages that have already been fetched from the server).
+ *
+ * It is possible to filter on local messages that are specific to a
+ * document with the `options` parameter.
+ *
+ * @param {Object} [options]
+ * @param {string} [options.documentModel] model of the document that the
+ * local messages of inbox must be linked to.
+ * @param {integer} [options.documentID] ID of the document that the local
+ * messages of inbox must be linked to.
+ */
+ getLocalMessages: function (options) {
+ var localMessages = this._cache['[]'].messages;
+ if (!options) {
+ return localMessages;
+ }
+ if (options.documentModel && options.documentID) {
+ return _.filter(localMessages, function (localMessage) {
+ return localMessage.getDocumentModel() === options.documentModel &&
+ localMessage.getDocumentID() === options.documentID;
+ });
+ }
+ },
+ /**
+ * Get the mailbox counter of this mailbox.
+ *
+ * @returns {integer}
+ */
+ getMailboxCounter: function () {
+ return this._mailboxCounter;
+ },
+ /**
+ * Get the preview of the messages from the inbox mailbox.
+ * If there are multiple messages associated to a similar document or
+ * channel, only keep the lastest message of this document/channel in the
+ * previews.
+ *
+ * @returns {$.Promise<Object[]>}
+ */
+ getMessagePreviews: function () {
+ var self = this;
+
+ return this.getMessages().then(function (messages) {
+ // pick only last message of chatter
+ // items = list of objects
+ // { unreadCounter: integer, message: mail.model.Message }
+ var items = [];
+ _.each(messages, function (message) {
+ var unreadCounter = 1;
+ var similarItem = _.find(items, function (item) {
+ return self._areMessagesFromSameDocumentThread(item.message, message) ||
+ self._areMessagesFromSameChannel(item.message, message);
+ });
+ if (similarItem) {
+ unreadCounter = similarItem.unreadCounter + 1;
+ var index = _.findIndex(items, similarItem);
+ items[index] = {
+ unreadCounter: unreadCounter,
+ message: message,
+ };
+ } else {
+ items.push({
+ unreadCounter: unreadCounter,
+ message: message,
+ });
+ }
+ });
+ return _.map(items, function (item) {
+ return _.extend(item.message.getPreview(), {
+ unreadCounter: item.unreadCounter,
+ });
+ });
+ });
+ },
+ /**
+ * Increment the counter of this mailbox
+ *
+ * @param {integer} [num=1] how many, the counter is incremented
+ */
+ incrementMailboxCounter: function (num) {
+ num = _.isNumber(num) ? num : 1;
+ this._mailboxCounter = Math.max(this._mailboxCounter + num, 0);
+ },
+ /**
+ * Marks all messages from the mailbox as read. At the moment, this method
+ * makes only sense for 'Inbox'.
+ *
+ * @param {Array} domain
+ * @return {$.Promise} resolved when all messages have been marked as read
+ * on the server
+ */
+ markAllMessagesAsRead: function (domain) {
+ if (this._id === 'mailbox_inbox' && this.getMailboxCounter() > 0) {
+ return this._rpc({
+ model: 'mail.message',
+ method: 'mark_all_as_read',
+ kwargs: {
+ channel_ids: [],
+ domain: domain,
+ },
+ });
+ }
+ return $.when();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Tell whether the two messages originate from the same channel.
+ * If any one of them does not originate from a channel, returns false.
+ *
+ * @private
+ * @param {mail.model.Message} message1
+ * @param {mail.model.Message} message2
+ * @returns {boolean}
+ */
+ _areMessagesFromSameChannel: function (message1, message2) {
+ return message1.originatesFromChannel() &&
+ message2.originatesFromChannel() &&
+ message1.getOriginChannelID() === message2.getOriginChannelID();
+ },
+ /**
+ * Tell whether the two messages are linked to the same document thread.
+ * If any one of them is not linked to a document, returns false.
+ *
+ * @private
+ * @param {mail.model.Message} message1
+ * @param {mail.model.Message} message2
+ * @returns {boolean}
+ */
+ _areMessagesFromSameDocumentThread: function (message1, message2) {
+ return message1.isLinkedToDocumentThread() &&
+ message2.isLinkedToDocumentThread() &&
+ message1.getDocumentModel() === message2.getDocumentModel() &&
+ message1.getDocumentID() === message2.getDocumentID();
+ },
+ /**
+ * Get the domain to fetch all the messages in the current mailbox
+ *
+ * @override
+ * @private
+ * @returns {Array}
+ * @throws on missing domain for the provided mailbox (mailboxes should
+ * always have a domain on messages to fetch)
+ */
+ _getThreadDomain: function () {
+ if (this._id === 'mailbox_inbox') {
+ return [['needaction', '=', true]];
+ } else if (this._id === 'mailbox_starred') {
+ return [['starred', '=', true]];
+ } else if (this._id === 'mailbox_moderation') {
+ return [['need_moderation', '=', true]];
+ } else {
+ throw _t(_.str("Missing domain for mailbox with ID '%s'", this._id));
+ }
+ },
+
+});
+
+return Mailbox;
+
+});
diff --git a/addons/mail/static/src/js/models/messages/abstract_message.js b/addons/mail/static/src/js/models/messages/abstract_message.js
new file mode 100644
index 00000000000..4cdb2273008
--- /dev/null
+++ b/addons/mail/static/src/js/models/messages/abstract_message.js
@@ -0,0 +1,409 @@
+odoo.define('mail.model.AbstractMessage', function (require) {
+"use strict";
+
+var mailUtils = require('mail.utils');
+
+var Class = require('web.Class');
+var core = require('web.core');
+var session = require('web.session');
+var time = require('web.time');
+
+var _t = core._t;
+
+/**
+ * This is an abstract class for modeling messages in JS.
+ * The purpose of this interface is to make im_livechat compatible with
+ * mail.widget.Thread, as this widget was designed to work with messages that
+ * are instances of mail.model.Messages.
+ *
+ * Ideally, im_livechat should also handle mail.model.Message, but this is not
+ * feasible for the moment, as mail.model.Message requires mail.Manager to work,
+ * and this module should not leak outside of the backend, hence the use of
+ * mail.model.AbstractMessage as a work-around.
+ */
+var AbstractMessage = Class.extend({
+
+ /**
+ * @param {Widget} parent
+ * @param {Object} data
+ * @param {Array} [data.attachment_ids=[]]
+ * @param {Array} [data.author_id]
+ * @param {string} [data.body = ""]
+ * @param {string} [data.date] the server-format date time of the message.
+ * If not provided, use current date time for this message.
+ * @param {integer} data.id
+ * @param {boolean} [data.is_discussion = false]
+ * @param {boolean} [data.is_notification = false]
+ * @param {string} [data.message_type = undefined]
+ */
+ init: function (parent, data) {
+ this._attachmentIDs = data.attachment_ids || [];
+ this._body = data.body || "";
+ // by default: current datetime
+ this._date = data.date ? moment(time.str_to_datetime(data.date)) : moment();
+ this._id = data.id;
+ this._isDiscussion = data.is_discussion;
+ this._isNotification = data.is_notification;
+ // if set, the message should display the author of the message
+ this._displayAuthor = false;
+ this._serverAuthorID = data.author_id;
+ this._type = data.message_type || undefined;
+
+ this._processAttachmentURL();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Set whether the message should display the author or not
+ *
+ * @param {boolean} bool if set, display the author of this message
+ */
+ displayAuthor: function (bool) {
+ this._displayAuthor = bool;
+ },
+ /**
+ * Get the list of files attached to this message.
+ * Note that attachments are stored with server-format
+ *
+ * @return {Object[]}
+ */
+ getAttachments: function () {
+ return this._attachmentIDs;
+ },
+ /**
+ * Get the server ID (number) of the author of this message
+ * If there are no author, return -1;
+ *
+ * @return {integer}
+ */
+ getAuthorID: function () {
+ if (!this.hasAuthor()) {
+ return -1;
+ }
+ return this._serverAuthorID[0];
+ },
+ /**
+ * Get the relative url of the avatar to display next to the message
+ *
+ * @abstract
+ * @return {string}
+ */
+ getAvatarSource: function () {},
+ /**
+ * Get the body content of this message
+ *
+ * @return {string}
+ */
+ getBody: function () {
+ return this._body;
+ },
+ /**
+ * @return {moment}
+ */
+ getDate: function () {
+ return this._date;
+ },
+ /**
+ * Get the date day of this message
+ *
+ * @return {string}
+ */
+ getDateDay: function () {
+ var date = this.getDate().format('YYYY-MM-DD');
+ if (date === moment().format('YYYY-MM-DD')) {
+ return _t("Today");
+ } else if (date === moment().subtract(1, 'days').format('YYYY-MM-DD')) {
+ return _t("Yesterday");
+ }
+ return this.getDate().format('LL');
+ },
+ /**
+ * Get the name of the author, if there is an author of this message
+ * If there are no author of this message, returns 'null'
+ *
+ * @return {string}
+ */
+ getDisplayedAuthor: function () {
+ return this.hasAuthor() ? this._getAuthorName() : null;
+ },
+ /**
+ * Get the server ID (number) of this message
+ *
+ * @override
+ * @return {integer}
+ */
+ getID: function () {
+ return this._id;
+ },
+ /**
+ * Get the list of images attached to this message.
+ * Note that attachments are stored with server-format
+ *
+ * @return {Object[]}
+ */
+ getImageAttachments: function () {
+ return _.filter(this.getAttachments(), function (attachment) {
+ return attachment.mimetype && attachment.mimetype.split('/')[0] === 'image';
+ });
+ },
+ /**
+ * Get the list of non-images attached to this message.
+ * Note that attachments are stored with server-format
+ *
+ * @return {Object[]}
+ */
+ getNonImageAttachments: function () {
+ return _.difference(this.getAttachments(), this.getImageAttachments());
+ },
+ /**
+ * Get the time elapsed between sent message and now
+ *
+ * @return {string}
+ */
+ getTimeElapsed: function () {
+ return mailUtils.timeFromNow(this.getDate());
+ },
+ /**
+ * Get the type of message (e.g. 'comment', 'email', 'notification', ...)
+ * By default, messages are of type 'undefined'
+ *
+ * @override
+ * @return {string|undefined}
+ */
+ getType: function () {
+ return this._type;
+ },
+ /**
+ * State whether this message contains some attachments.
+ *
+ * @override
+ * @return {boolean}
+ */
+ hasAttachments: function () {
+ return this.getAttachments().length > 0;
+ },
+ /**
+ * State whether this message has an author
+ *
+ * @return {boolean}
+ */
+ hasAuthor: function () {
+ return !!(this._serverAuthorID && this._serverAuthorID[0]);
+ },
+ /**
+ * State whether this message contains some customer email data
+ * By default, messages do not have any customer email data
+ *
+ * @return {boolean}
+ */
+ hasCustomerEmailData: function () {
+ return false;
+ },
+ /**
+ * State whether this message has an email of its sender.
+ * By default, messages do not have any email of its sender.
+ *
+ * @return {string}
+ */
+ hasEmailFrom: function () {
+ return false;
+ },
+ /**
+ * State whether this image contains images attachments
+ *
+ * @return {boolean}
+ */
+ hasImageAttachments: function () {
+ return _.some(this.getAttachments(), function (attachment) {
+ return attachment.mimetype && attachment.mimetype.split('/')[0] === 'image';
+ });
+ },
+ /**
+ * State whether this image contains non-images attachments
+ *
+ * @return {boolean}
+ */
+ hasNonImageAttachments: function () {
+ return _.some(this.getAttachments(), function (attachment) {
+ return !(attachment.mimetype && attachment.mimetype.split('/')[0] === 'image');
+ });
+ },
+ /**
+ * State whether this message originates from a channel.
+ * By default, messages do not originate from a channel.
+ *
+ * @override
+ * @return {boolean}
+ */
+ originatesFromChannel: function () {
+ return false;
+ },
+ /**
+ * State whether this message has a subject
+ * By default, messages do not have any subject.
+ *
+ * @return {boolean}
+ */
+ hasSubject: function () {
+ return false;
+ },
+ /**
+ * By default, messages do not have any subtype description
+ *
+ * @return {boolean}
+ */
+ hasSubtypeDescription: function () {
+ return false;
+ },
+ /**
+ * State whether this message contains some tracking values
+ * By default, messages do not have any tracking values.
+ *
+ * @return {boolean}
+ */
+ hasTrackingValues: function () {
+ return false;
+ },
+ /**
+ * State whether this message is a discussion
+ *
+ * @return {boolean}
+ */
+ isDiscussion: function () {
+ return this._isDiscussion;
+ },
+ /**
+ * State whether this message is linked to a document
+ * By default, messages are not linked to a document.
+ *
+ * @return {boolean}
+ */
+ isLinkedToDocument: function () {
+ return false;
+ },
+ /**
+ * State whether this message is needaction
+ * By default, messages are not needaction.
+ *
+ * @return {boolean}
+ */
+ isNeedaction: function () {
+ return false;
+ },
+ /**
+ * State whether this message is a note (i.e. a message from "Log note")
+ *
+ * @return {boolean}
+ */
+ isNote: function () {
+ return this._isNote;
+ },
+ /**
+ * State whether this message is a notification
+ *
+ * User notifications are defined as either
+ * - notes
+ * - pushed to user Inbox or email through classic notification process
+ * - not linked to any document, meaning model and res_id are void
+ *
+ * This is useful in order to display white background for user
+ * notifications in chatter
+ *
+ * @returns {boolean}
+ */
+ isNotification: function () {
+ return this._isNotification;
+ },
+ /**
+ * State whether this message is starred
+ * By default, messages are not starred.
+ *
+ * @return {boolean}
+ */
+ isStarred: function () {
+ return false;
+ },
+ /**
+ * State whether this message is a system notification
+ * By default, messages are not system notifications
+ *
+ * @override
+ * @return {boolean}
+ */
+ isSystemNotification: function () {
+ return false;
+ },
+ /**
+ * States whether the current message needs moderation in general.
+ * By default, messages do not require any moderation.
+ *
+ * @returns {boolean}
+ */
+ needsModeration: function () {
+ return false;
+ },
+ /**
+ * State whether this message should display the author
+ *
+ * @return {boolean}
+ */
+ shouldDisplayAuthor: function () {
+ return this._displayAuthor;
+ },
+ /**
+ * State whether this message should redirect to the author
+ * when clicking on the author of this message.
+ *
+ * Do not redirect on author clicked of self-posted messages.
+ *
+ * @return {boolean}
+ */
+ shouldRedirectToAuthor: function () {
+ return !this._isAuthor();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the name of the author of this message.
+ * If there are no author of this messages, returns '' (empty string).
+ *
+ * @private
+ * @returns {string}
+ */
+ _getAuthorName: function () {
+ if (!this.hasAuthor()) {
+ return "";
+ }
+ return this._serverAuthorID[1];
+ },
+ /**
+ * State whether the current user is the author of this message
+ *
+ * @private
+ * @return {boolean}
+ */
+ _isAuthor: function () {
+ return this.hasAuthor() && (this.getAuthorID() === session.partner_id);
+ },
+ /**
+ * Compute url of attachments of this message
+ *
+ * @private
+ */
+ _processAttachmentURL: function () {
+ _.each(this.getAttachments(), function (attachment) {
+ attachment.url = '/web/content/' + attachment.id + '?download=true';
+ });
+ },
+
+});
+
+return AbstractMessage;
+
+});
diff --git a/addons/mail/static/src/js/models/messages/message.js b/addons/mail/static/src/js/models/messages/message.js
new file mode 100644
index 00000000000..06b776ea580
--- /dev/null
+++ b/addons/mail/static/src/js/models/messages/message.js
@@ -0,0 +1,780 @@
+odoo.define('mail.model.Message', function (require) {
+"use strict";
+
+var emojis = require('mail.emojis');
+var AbstractMessage = require('mail.model.AbstractMessage');
+var mailUtils = require('mail.utils');
+
+var core = require('web.core');
+var Mixins = require('web.mixins');
+var ServicesMixin = require('web.ServicesMixin');
+var session = require('web.session');
+
+var _t = core._t;
+
+/**
+ * This is the main class for the modeling messages in JS.
+ * Such messages are stored in the mail manager, and any piece of JS code whose
+ * logic relies on threads must ideally interact with such objects.
+ */
+var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
+ /**
+ * @override
+ * @param {web.Manager} parent
+ * @param {Object} data
+ * @param {string} [data.body = ""]
+ * @param {(string|integer)[]} [data.channel_ids]
+ * @param {Object[]} [data.customer_email_data]
+ * @param {string} [data.customer_email_status]
+ * @param {string} [data.email_from]
+ * @param {string} [data.info]
+ * @param {string} [data.model]
+ * @param {string} [data.moderation_status='accepted']
+ * @param {string} [data.module_icon]
+ * @param {Array} [data.needaction_partner_ids = []]
+ * @param {string} [data.record_name]
+ * @param {integer} [data.res_id]
+ * @param {Array} [data.starred_partner_ids = []]
+ * @param {string} [data.subject]
+ * @param {string} [data.subtype_description]
+ * @param {Object[]} [data.tracking_value_ids]
+ * @param {Object[]} emojis
+ */
+ init: function (parent, data, emojis) {
+ this._super.apply(this, arguments);
+
+ Mixins.EventDispatcherMixin.init.call(this, arguments);
+ this.setParent(parent);
+
+ this._customerEmailData = data.customer_email_data || [];
+ this._customerEmailStatus = data.customer_email_status;
+ this._documentModel = data.model;
+ this._documentName = data.record_name;
+ this._documentID = data.res_id;
+ this._emailFrom = data.email_from;
+ this._info = data.info;
+ this._moduleIcon = data.module_icon;
+ this._needactionPartnerIDs = data.needaction_partner_ids || [];
+ this._starredPartnerIDs = data.starred_partner_ids || [];
+ this._subject = data.subject;
+ this._subtypeDescription = data.subtype_description;
+ this._threadIDs = data.channel_ids || [];
+ this._trackingValueIDs = data.tracking_value_ids;
+
+ this._moderationStatus = data.moderation_status || 'accepted';
+
+ this._processBody(emojis);
+ this._processMailboxes();
+ this._processModeration();
+ this._processDocumentThread();
+ this._processTrackingValues();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {Object} data
+ */
+ addCustomerEmailData: function (data) {
+ this._customerEmailData.push(data);
+ },
+ /**
+ * Get the name of the author of this message
+ * If there are no author, return "".
+ *
+ * @return {string}
+ */
+ getAuthorName: function () {
+ return this._getAuthorName();
+ },
+ /**
+ * Get the relative url of the avatar to display next to the message
+ *
+ * @override
+ * @return {string}
+ */
+ getAvatarSource: function () {
+ if (this._isOdoobotAuthor()) {
+ return '/mail/static/src/img/odoo_o.png';
+ } else if (this.hasAuthor()) {
+ return '/web/image/res.partner/' + this.getAuthorID() + '/image_small';
+ } else if (this.getType() === 'email') {
+ return '/mail/static/src/img/email_icon.png';
+ }
+ return '/mail/static/src/img/smiley/avatar.jpg';
+ },
+ /**
+ * Get the customer email data of this email, if any.
+ * If this message has no such data, returns 'undefined'
+ *
+ * @return {Object[]|undefined}
+ */
+ getCustomerEmailData: function () {
+ if (!this.hasCustomerEmailData()) {
+ return undefined;
+ }
+ return this._customerEmailData;
+ },
+ /**
+ * Get the customer email status of this email, if any.
+ * If this message has no such data, returns 'undefined'
+ *
+ * @return {string|undefined}
+ */
+ getCustomerEmailStatus: function () {
+ if (!this.hasCustomerEmailData()) {
+ return undefined;
+ }
+ return this._customerEmailStatus;
+ },
+ /**
+ * Get the text to display for the author of the message
+ *
+ * Rule of precedence for the displayed author:
+ *
+ * author name > sender email > "anonymous"
+ *
+ * @override
+ * @return {string}
+ */
+ getDisplayedAuthor: function () {
+ var res = this._super.apply(this, arguments);
+ if (res) {
+ return res;
+ } else {
+ return this.hasEmailFrom() ? this.getEmailFrom() :
+ _t("Anonymous");
+ }
+ },
+ /**
+ * Get the ID of the document that this message is linked.
+ *
+ * @override
+ * @return {integer}
+ */
+ getDocumentID: function () {
+ return this._documentID;
+ },
+ /**
+ * Get the model of the document that this message is linked.
+ * If this message is not linked to a document, returns "".
+ *
+ * @override
+ * @return {string}
+ */
+ getDocumentModel: function () {
+ return this._documentModel;
+ },
+ /**
+ * Get the name of the document that this message is linked.
+ * If this message is not linked to a document, returns "".
+ *
+ * It uses the name of an existing document thread, in order to have the
+ * updated document name. If there is no document thread yet, use the
+ * currently aware document name.
+ *
+ * As a result, this method should ideally be called internally, in order
+ * to avoid having different names for messages linked to a same modified
+ * document.
+ *
+ * @override
+ * @return {string}
+ */
+ getDocumentName: function () {
+ if (!this.isLinkedToDocumentThread()) {
+ return "";
+ }
+ var model = this.getDocumentModel();
+ var id = this.getDocumentID();
+ var documentThread = this.call('mail_service', 'getDocumentThread', model, id);
+ if (documentThread) {
+ this._documentName = documentThread.getName();
+ }
+ return this._documentName;
+ },
+ /**
+ * Get the email of the sender of this message.
+ * If this email has no sender email, returns "".
+ *
+ * @return {string}
+ */
+ getEmailFrom: function () {
+ if (!this.hasEmailFrom()) {
+ return "";
+ }
+ return this._emailFrom;
+ },
+ /**
+ * Get the ID of the channel that this message originates from.
+ * If this message does not originate from a channel, returns `-1`.
+ *
+ * @override
+ * @return {integer}
+ */
+ getOriginChannelID: function () {
+ if (!this.originatesFromChannel()) {
+ return -1;
+ }
+ return this._documentID;
+ },
+ /**
+ * Get the name of the channel that this message originates from.
+ * If this message does not originate from a channel, returns "".
+ *
+ * @override
+ * @returns {string}
+ */
+ getOriginChannelName: function () {
+ if (!this.originatesFromChannel()) {
+ return "";
+ }
+ var originChannelID = this.getOriginChannelID();
+ var channel = originChannelID && this.call(
+ 'mail_service',
+ 'getChannel',
+ originChannelID);
+ if (!channel) {
+ return "";
+ }
+ return channel.getName();
+ },
+ /**
+ * Returns the information required to render the preview of this channel.
+ *
+ * @return {Object}
+ */
+ getPreview: function () {
+ var id, title;
+ if (this.isLinkedToDocumentThread()) {
+ id = this.getDocumentModel() + '_' + this.getDocumentID();
+ title = this.getDocumentName();
+ } else {
+ id = 'mailbox_inbox';
+ title = this.hasSubject() ? this.getSubject() : this.getDisplayedAuthor();
+ }
+ return {
+ author: this.getDisplayedAuthor(),
+ body: mailUtils.parseAndTransform(this.getBody(), mailUtils.inline),
+ date: this.getDate(),
+ documentModel: this.getDocumentModel(),
+ documentID: this.getDocumentID(),
+ id: id,
+ imageSRC: this._getModuleIcon() || this.getAvatarSource(),
+ status: this.status,
+ title: title,
+ };
+ },
+ /**
+ * Get the subject of this message
+ * If this message has no subject, returns "".
+ *
+ * @override
+ * @return {string}
+ */
+ getSubject: function () {
+ if (!this.hasSubject()) {
+ return "";
+ }
+ return this._subject;
+ },
+ /**
+ * @override
+ * @return {string}
+ */
+ getSubtypeDescription: function () {
+ return this._subtypeDescription;
+ },
+ /**
+ * Get the list of thread IDs that this message is linked to
+ * If this message is not linked to a thread, returns 'undefined'
+ *
+ * @return {string[]|undefined} list of thread IDs, if any
+ */
+ getThreadIDs: function () {
+ return this._threadIDs;
+ },
+ /**
+ * Get the tracking values of this message
+ * If this message has no tracking values, returns 'undefined'
+ *
+ * @override
+ * @return {Object[]|undefined}
+ */
+ getTrackingValues: function () {
+ if (!this.hasTrackingValues()) {
+ return undefined;
+ }
+ return this._trackingValueIDs;
+ },
+ /**
+ * @override
+ * @return {string}
+ */
+ getURL: function () {
+ return session.url('/mail/view?message_id=' + this._id);
+ },
+ /**
+ * State whether this message contains some customer email data
+ *
+ * @override
+ * @return {boolean}
+ */
+ hasCustomerEmailData: function () {
+ return !!(this._customerEmailData && (this._customerEmailData.length > 0));
+ },
+ /**
+ * State whether this message has an email of its sender.
+ *
+ * @override
+ * @return {string}
+ */
+ hasEmailFrom: function () {
+ return !!(this._emailFrom);
+ },
+ /**
+ * State whether this message has a subject
+ *
+ * @return {boolean}
+ */
+ hasSubject: function () {
+ return !!(this._subject);
+ },
+ /**
+ * @override
+ * @return {boolean}
+ */
+ hasSubtypeDescription: function () {
+ return !!(this._subtypeDescription);
+ },
+ /**
+ * State whether this message contains some tracking values
+ *
+ * @override
+ * @return {boolean}
+ */
+ hasTrackingValues: function () {
+ return !!(this._trackingValueIDs && (this._trackingValueIDs.length > 0));
+ },
+ /**
+ * State whether the current user is the author of this message
+ *
+ * @return {boolean}
+ */
+ isAuthor: function () {
+ return this._isAuthor();
+ },
+ /**
+ * State whether this message is linked to a document thread (not channel)
+ *
+ * Usually, if this is true, then this message comes from a document thread,
+ * but the document model could be a channel. In that case, the document
+ * resID tells the channel that this message originally comes from.
+ *
+ * To detect whether the message comes from a channel, see the method
+ * 'originatesFromChannel'.
+ *
+ * @override
+ * @return {boolean}
+ */
+ isLinkedToDocumentThread: function () {
+ return !!(this._documentModel !== 'mail.channel' && this._documentID);
+ },
+ /**
+ * States whether the current message needs moderation in general.
+ *
+ * @override
+ * @returns {boolean}
+ */
+ needsModeration: function () {
+ return this._moderationStatus === 'pending_moderation';
+ },
+ /**
+ * States whether the current message needs moderation by the current user.
+ * Such a message should be in the moderation mailbox.
+ *
+ * @returns {boolean}
+ */
+ needsModerationByUser: function () {
+ return _.contains(this._threadIDs, 'mailbox_moderation');
+ },
+ /**
+ * State whether this message is needaction
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isNeedaction: function () {
+ return _.contains(this._threadIDs, 'mailbox_inbox');
+ },
+ /**
+ * State whether this message is starred
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isStarred: function () {
+ return _.contains(this._threadIDs, 'mailbox_starred');
+ },
+ /**
+ * State whether this message is a system notification
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isSystemNotification: function () {
+ return (
+ this.getType() === 'notification' &&
+ this._documentModel === 'mail.channel'
+ ) || this._isTransient();
+ },
+ /**
+ * States whether the message originates from a channel or not
+ *
+ * @returns {boolean}
+ */
+ originatesFromChannel: function () {
+ return this._documentModel === 'mail.channel';
+ },
+ /**
+ * Unregister thread with ID `threadID` from this message
+ *
+ * @param {string|integer} threadID ID of thread
+ */
+ removeThread: function (threadID) {
+ this._threadIDs = _.without(this._threadIDs, threadID);
+ },
+ /**
+ * Update the moderation status of the message, so that it is now accepted
+ * or rejected. When the message is accepted, it may be linked to more
+ * threads, which is the case for relay channels on moderated channels.
+ *
+ * @param {string} newModerationStatus ['accepted', 'rejected']
+ * @param {Object} [options]
+ * @param {Object} [options.additionalThreadIDs] contains additional thread
+ * IDs to be registered on the message.
+ */
+ setModerationStatus: function (newModerationStatus, options) {
+ var self = this;
+ this._moderationStatus = newModerationStatus;
+ if (newModerationStatus === 'accepted' && options) {
+ _.each(options.additionalThreadIDs, function (threadID) {
+ self._addThread(threadID);
+ });
+ }
+ this._warnMessageModerated();
+ },
+ /**
+ * Set whether the message is starred or not.
+ * If it is starred, the message is moved to the "Starred" mailbox.
+ * Note that this function only applies it locally, the server is not aware
+ *
+ * @param {boolean} starred if set, the message is starred
+ */
+ setStarred: function (starred) {
+ if (starred) {
+ this._addThread('mailbox_starred');
+ } else {
+ this.removeThread('mailbox_starred');
+ }
+ },
+ /**
+ * State whether this message should display the subject
+ *
+ * @return {boolean}
+ */
+ shouldDisplaySubject: function () {
+ return this.hasSubject() &&
+ this.getType() !== 'notification' &&
+ !this.originatesFromChannel();
+ },
+ /**
+ * State whether this message should redirect to the author
+ * when clicking on the author of this message.
+ *
+ * Do not redirect on author clicked of self-posted or Odoobot messages
+ * (note: Odoobot is the default author of transient messages)
+ *
+ * @override
+ * @return {boolean}
+ */
+ shouldRedirectToAuthor: function () {
+ return this._super.apply(this, arguments) && !this._isOdoobotAuthor();
+ },
+ /**
+ * Toggle the star status of the message
+ *
+ * It relies on the star status of the message from the date of the server.
+ * The star status is updated from a 'toggle_star' notification on the
+ * longpoll bus
+ *
+ * @see {mail.Manager.Notification} for the receipt of 'toggle_star'
+ * notification after this rpc.
+ *
+ * @return {$.Promise}
+ */
+ toggleStarStatus: function () {
+ return this._rpc({
+ model: 'mail.message',
+ method: 'toggle_message_starred',
+ args: [[this._id]],
+ });
+ },
+ /**
+ * Update the customer email status
+ *
+ * @param {string} newCustomerEmailStatus
+ */
+ updateCustomerEmailStatus: function (newCustomerEmailStatus) {
+ this._customerEmailStatus = newCustomerEmailStatus;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Register a thread to the message
+ * Useful when you mark a message as 'to do'.
+ * This message will be available in 'Starred' mailbox.
+ *
+ * @private
+ * @param {string|integer} threadID
+ */
+ _addThread: function (threadID) {
+ if (!this._threadIDs) {
+ this._threadIDs = [];
+ }
+ this._threadIDs.push(threadID);
+ this._threadIDs = _.uniq(this._threadIDs);
+ },
+ /**
+ * Get the name of the author of this message.
+ * If Odoobot is the author (default author for transient messages),
+ * returns 'Odoobot'.
+ *
+ * @override
+ * @private
+ * @returns {string}
+ */
+ _getAuthorName: function () {
+ if (this._isOdoobotAuthor()) {
+ return "Odoobot";
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @private
+ * @return {string}
+ */
+ _getModuleIcon: function () {
+ return this._moduleIcon;
+ },
+ /**
+ * State if the author of this message is Odoobot
+ * This is the default author for transient messages.
+ *
+ * @private
+ * @return {boolean}
+ */
+ _isOdoobotAuthor: function () {
+ return this._serverAuthorID === this.call('mail_service', 'getOdoobotID');
+ },
+ /**
+ * State whether the message is transient or not
+ *
+ * @private
+ * @return {boolean}
+ */
+ _isTransient: function () {
+ return this._info === 'transient_message';
+ },
+ /**
+ * Convert the server-format body of the message to the client-format.
+ * Basically, it processes emojis and url.
+ *
+ * @private
+ */
+ _processBody: function () {
+ var self = this;
+
+ _.each(emojis, function (emoji) {
+ var unicode = emoji.unicode;
+ var regexp = new RegExp("(?:^|\\s|<[a-z]*>)(" + unicode + ")(?=\\s|$|</[a-z]*>)", 'g');
+ var originalBody = self.body;
+ self._body = self._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(self._body, 'o_mail_emoji') > 200) {
+ self._body = originalBody;
+ }
+ });
+
+ // add anchor tags to urls
+ self._body = mailUtils.parseAndTransform(self._body, mailUtils.addLink);
+ },
+ /**
+ * If the message is linked to a document, link this message to the related
+ * document thread. If there is no document thread yet, create it
+ */
+ _processDocumentThread: function () {
+ if (!this.isLinkedToDocumentThread()) {
+ return;
+ }
+ var resModel = this.getDocumentModel();
+ var resID = this.getDocumentID();
+ var documentThread = this.call('mail_service', 'getOrAddDocumentThread', {
+ resModel: resModel,
+ resID: resID,
+ name: this.getDocumentName(),
+ });
+ this._threadIDs.push(documentThread.getID());
+ },
+ /**
+ * Set the appropriate mailboxes to this message based on server data
+ *
+ * @private
+ */
+ _processMailboxes: function () {
+ if (_.contains(this._needactionPartnerIDs, session.partner_id)) {
+ this._setNeedaction(true);
+ }
+ if (_.contains(this._starredPartnerIDs, session.partner_id)) {
+ this.setStarred(true);
+ }
+ if (
+ this.originatesFromChannel() &&
+ _.contains(
+ this.call('mail_service', 'getModeratedChannelIDs'),
+ this.getOriginChannelID()
+ ) &&
+ this.needsModeration()
+ ) {
+ this._setModeratedByUser(true);
+ }
+ },
+ /**
+ * Do some extra processing at message init, related to the
+ * moderated status of the message.
+ *
+ * If the message needs moderation, it is not yet linked to the
+ * moderated channel server-side. Therefore, it is not registered
+ * in the list of threadIDs, as it is built on server-side information
+ * at message initialisation (using data.channel_ids).
+ *
+ * Since the web client uses the list of thread IDs to show visually the
+ * message in a thread, we should hack the response of the server so that
+ * it assumes the message really belongs to this thread.
+ *
+ * @private
+ */
+ _processModeration: function () {
+ if (this.needsModeration()) {
+ // the message is not linked to the moderated channel on the
+ // server, therefore this message has not this channel in
+ // channel_ids. Here, just to show this message in the channel
+ // visually, it links this message to the channel
+ this._threadIDs.push(this.getOriginChannelID());
+ }
+ },
+ /**
+ * Process the tracking values on message creation, which
+ * basically format date to the local only once by message
+ *
+ * Cannot be done in preprocess, since it alter the original value
+ *
+ * @private
+ */
+ _processTrackingValues: function () {
+ if (this.hasTrackingValues()) {
+ _.each(this.getTrackingValues(), function (trackingValue) {
+ if (trackingValue.field_type === 'datetime') {
+ if (trackingValue.old_value) {
+ trackingValue.old_value =
+ moment
+ .utc(trackingValue.old_value)
+ .local()
+ .format('LLL');
+ }
+ if (trackingValue.new_value) {
+ trackingValue.new_value =
+ moment
+ .utc(trackingValue.new_value)
+ .local()
+ .format('LLL');
+ }
+ } else if (trackingValue.field_type === 'date') {
+ if (trackingValue.old_value) {
+ trackingValue.old_value =
+ moment(trackingValue.old_value)
+ .local()
+ .format('LL');
+ }
+ if (trackingValue.new_value) {
+ trackingValue.new_value =
+ moment(trackingValue.new_value)
+ .local()
+ .format('LL');
+ }
+ }
+ });
+ }
+ },
+ /**
+ * Set whether the message is moderated by current user or not.
+ * If it is moderated by the current user, the message is moved to the
+ * "Moderation" mailbox. Note that this function only applies it locally,
+ * the server is not aware
+ *
+ * @private
+ * @param {boolean} moderated if set, the message is moderated by user
+ */
+ _setModeratedByUser: function (moderated) {
+ if (moderated) {
+ this._addThread('mailbox_moderation');
+ } else {
+ this.removeThread('mailbox_moderation');
+ }
+ },
+ /**
+ * Set whether the message is needaction or not.
+ * If it is needaction, the message is moved to the "Inbox" mailbox.
+ * Note that this function only applies it locally, the server is not aware
+ *
+ * @private
+ * @param {boolean} needaction if set, the message is needaction
+ */
+ _setNeedaction: function (needaction) {
+ if (needaction) {
+ this._addThread('mailbox_inbox');
+ } else {
+ this.removeThread('mailbox_inbox');
+ }
+ },
+ /**
+ * @private
+ */
+ _warnMessageModerated: function () {
+ var mailBus = this.call('mail_service', 'getMailBus');
+ if (this.needsModerationByUser()) {
+ var moderationBox = this.call('mail_service', 'getMailbox', 'moderation');
+ moderationBox.decrementMailboxCounter();
+ moderationBox.removeMessage(this.getID());
+ mailBus.trigger('update_moderation_counter');
+ }
+ if (this._moderationStatus !== 'accepted') {
+ this.call('mail_service', 'removeMessageFromThreads', this);
+ }
+ mailBus.trigger('update_message', this);
+ },
+
+});
+
+return Message;
+
+});
diff --git a/addons/mail/static/src/js/models/thread.js b/addons/mail/static/src/js/models/thread.js
new file mode 100644
index 00000000000..de2c6628d67
--- /dev/null
+++ b/addons/mail/static/src/js/models/thread.js
@@ -0,0 +1,446 @@
+odoo.define('mail.model.Thread', function (require) {
+"use strict";
+
+var emojis = require('mail.emojis');
+var mailUtils = require('mail.utils');
+
+var Class = require('web.Class');
+var Mixins = require('web.mixins');
+var ServicesMixin = require('web.ServicesMixin');
+
+/**
+ * This is the super class modeling threads in JS.
+ * Such threads are stored in the mail manager, and any piece of JS code whose
+ * logic relies on threads must ideally interact with such objects.
+ *
+ * In particular, channels and mailboxes are two different kinds of threads.
+ */
+var Thread = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
+ /**
+ * @param {mail.Manager} parent
+ * @param {Object} data
+ * @param {string} [data.channel_type]
+ * @param {integer|string} data.id
+ * @param {string} data.name
+ * @param {string} [data.type]
+ */
+ init: function (parent, data) {
+ Mixins.EventDispatcherMixin.init.call(this, arguments);
+ this.setParent(parent);
+
+ this._id = data.id;
+
+ // threads are not detached by default
+ this._detached = false;
+ // threads are unfolded by default
+ this._folded = false;
+ // if this._massMailing is set, display subject on messages, use
+ // extended composer and show "Send by messages by email" on discuss
+ // sidebar
+ this._massMailing = false;
+ this._name = data.name;
+ // on 1st request to getPreview, fetch data if incomplete. Otherwise it
+ // means that there is no message in this channel.
+ this._previewed = false;
+ this._type = data.type || data.channel_type;
+ // any new message that has not been read yet
+ this._unreadCounter = 0;
+ // max number of fetched messages from the server
+ this._FETCH_LIMIT = 30;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a message to this thread.
+ *
+ * @abstract
+ * @param {mail.model.Message} message
+ */
+ addMessage: function (message) {},
+ /**
+ * Updates the _detached state of the thread. Must be overriden to reflect
+ * the new state in the interface.
+ */
+ close: function () {
+ this._detached = false;
+ this._folded = false;
+ this._warnUpdatedWindowState();
+ },
+ /**
+ * Updates the _detached state of the thread. Must be overriden to reflect
+ * the new state in the interface.
+ *
+ * @param {Object} [options={}]
+ * @param {boolean} [options.keepFoldState=false] if set, keep the fold state
+ * of this thread. Otherwise unfold it while detaching it.
+ * @param {boolean} [options.passively=false] if set, if the thread window
+ * will be created passively.
+ */
+ detach: function (options) {
+ options = options || {};
+ this._detached = true;
+ this._folded = options.keepFoldState ? this._folded : false;
+ this._warnUpdatedWindowState({
+ passively: options.passively,
+ });
+ },
+ /**
+ * Updates the _folded state of the thread. Must be overriden to reflect
+ * the new state in the interface.
+ */
+ fold: function (folded) {
+ this._detached = true;
+ this._folded = folded;
+ this._warnUpdatedWindowState();
+ },
+ /**
+ * Get the list of available commands for the thread.
+ * By default, threads do not have any available command.
+ *
+ * @returns {Array}
+ */
+ getCommands: function () {
+ return [];
+ },
+ /**
+ * Get the ID of the thread
+ *
+ * @returns {integer|string} string for mailboxes (e.g. 'mailbox_inbox')
+ */
+ getID: function () {
+ return this._id;
+ },
+ /**
+ * Get the listeners of the thread.
+ * By default, a thread has not listener.
+ *
+ * @abstract
+ * @returns {$.Promise<Object[]>}
+ */
+ getMentionPartnerSuggestions: function () {
+ return $.when([]);
+ },
+ /**
+ * Get the list of messages in this thread.
+ * By default, a thread has no messages.
+ *
+ * Note that this method only returns some messages, as we do not want to
+ * fetch all messages of a thread at once, just to read the last message.
+ *
+ * As a result, this method fetches only some messages of the thread,
+ * starting from the last message in the thread. At most, it fetches
+ * `this.LIMIT` number of messages at a time.
+ *
+ * @abstract
+ * @returns {$.Promise<mail.model.Message[]>}
+ */
+ getMessages: function () {
+ return $.when([]);
+ },
+ /**
+ * Get the name of this thread
+ *
+ * @returns {string|Object} object for lazy translated names with _lt
+ */
+ getName: function () {
+ return this._name;
+ },
+ /**
+ * Returns the information required to render the preview of this channel.
+ *
+ * @returns {Object} a valid object for the rendering of previews
+ * (@see mail.Preview template)
+ */
+ getPreview: function () {
+ return {
+ id: this.getID(),
+ imageSRC: '/mail/static/src/img/smiley/avatar.jpg',
+ isChat: this.isChat(),
+ status: this.getStatus(),
+ title: this.getName(),
+ unreadCounter: this.getUnreadCounter(),
+ };
+ },
+ /**
+ * Get the status of the thread (e.g. 'online', 'offline', etc.)
+ *
+ * @returns {string}
+ */
+ getStatus: function () {
+ return this._status;
+ },
+ /**
+ * Returns the title to display in thread window's headers.
+ *
+ * @returns {string|Object} the name of the thread by default (see @getName)
+ */
+ getTitle: function () {
+ return this.getName();
+ },
+ /**
+ * @returns {string}
+ */
+ getType: function () {
+ return this._type;
+ },
+ /**
+ * @returns {integer}
+ */
+ getUnreadCounter: function () {
+ return this._unreadCounter;
+ },
+ /**
+ * State whether this channel has been previewed
+ *
+ * A channel that has been previewed means that it had the necessary data
+ * to display its preview format. A channel needs its meta data and the
+ * last message in order to build its preview format.
+ *
+ * This is useful in order to not fetch preview info on this channel more
+ * than once on channels that have no message at all.
+ *
+ * Any received message updates the last_message, so a channel should
+ * always have all the necessary information to display its preview after
+ * the 1st time.
+ *
+ * @returns {boolean}
+ */
+ hasBeenPreviewed: function () {
+ return this._previewed;
+ },
+ /**
+ * State whether there are unread messages in this thread
+ *
+ * @returns {boolean}
+ */
+ hasUnreadMessages: function () {
+ return this._unreadCounter !== 0;
+ },
+ /**
+ * Increments the needaction counter of this thread
+ * FIXME: this method makes only sense for channels. Not sure, but I think
+ * this method is necessary at this level because of the model
+ * im_support.SupportChannel
+ *
+ * @abstract
+ */
+ incrementNeedactionCounter: function () {},
+ /**
+ * Increment the unread counter of this thread by 1 unit
+ */
+ incrementUnreadCounter: function () {
+ this._unreadCounter++;
+ this._warnNewUnreadCounter();
+ },
+ /**
+ * States whether the thread should be auto-selected on creation
+ *
+ * Note that this is not of the responsibility of the thread: it only
+ * provides guidance for the object that uses threads (e.g. mail.Discuss
+ * must listen on threads and auto-select the thread if autoswitch is set).
+ *
+ * By default, threads are not in autoswitch mode.
+ *
+ * @returns {boolean}
+ */
+ isAutoswitch: function () {
+ return false;
+ },
+ /**
+ * States whether this thread is a channel or not. A thread is a channel if
+ * it is an instance of mail.model.Channel (direct or indirect).
+ * By default, any thread is not a channel
+ *
+ * @returns {boolean}
+ */
+ isChannel: function () {
+ return false;
+ },
+ /**
+ * States whether this thread is a chat or not. In particular, public and
+ * private channels are not chat, but DMs and Livechats are chats. Chats
+ * are threads used for communication between two users.
+ * By default, any thread is not a chat
+ *
+ * @returns {boolean}
+ */
+ isChat: function () {
+ return false;
+ },
+ /**
+ * States whether this thread is detached or not.
+ * A thread that is detached must have a thread window linked to itself.
+ *
+ * @return {boolean}
+ */
+ isDetached: function () {
+ return this._detached;
+ },
+ /**
+ * States whether this thread is folded or not.
+ *
+ * @return {boolean}
+ */
+ isFolded: function () {
+ return this._folded;
+ },
+ /**
+ * States whether the thread is linked to a document
+ * By default, threads are not linked to a document.
+ *
+ * @returns {boolean}
+ */
+ isLinkedToDocument: function () {
+ return false;
+ },
+ /**
+ * States whether this thread has the mass mailing setting active or not.
+ * This is a server-side setting, that determine the type of composer that
+ * is used (basic or extended composer).
+ *
+ * @return {boolean}
+ */
+ isMassMailing: function () {
+ return this._massMailing;
+ },
+ /**
+
+ * States whether the thread is moderated or not.
+ * By default, threads are not moderated.
+ *
+ * @returns {boolean}
+ */
+ isModerated: function () {
+ return false;
+ },
+ /**
+ * States whether the current user is moderator of this thread.
+ * By default, the current user is not moderator of this thread.
+ *
+ * @returns {boolean}
+ */
+ isModerator: function () {
+ return false;
+ },
+ /**
+ * Mark this channel as previewed
+ *
+ * This is useful in order to not fetch preview info on this channel
+ * is the server has no preview in the first place.
+ *
+ * Note: preview fetch is useful only when the channel contains messages
+ * that have not been fetched at all. After that, this channel instance
+ * is updated regularly so that the most up-to-date info are available
+ * to make the preview of this channel.
+ */
+ markAsPreviewed: function () {
+ this._previewed = true;
+ },
+ /**
+ * Mark the thread as read, which resets the unread counter to 0.
+ *
+ * @returns {$.Promise} resolved
+ */
+ markAsRead: function () {
+ if (this._unreadCounter > 0) {
+ this._unreadCounter = 0;
+ this._warnNewUnreadCounter();
+ }
+ return $.when();
+ },
+ /**
+ * Post a message on the thread.
+ *
+ * This method must be completed by concrete threads,
+ * As it currently only pre-process the messages at the moment.
+ *
+ * @abstract
+ * @param {Object} data
+ * @returns {$.Promise<Object>} resolved with the message object to be sent
+ * to the server
+ */
+ postMessage: function (data) {
+ // This message will be received from the mail composer as html content
+ // subtype but the urls will not be linkified. If the mail composer
+ // takes the responsibility to linkify the urls we end up with double
+ // linkification a bit everywhere. Ideally we want to keep the content
+ // as text internally and only make html enrichment at display time but
+ // the current design makes this quite hard to do.
+ var body = mailUtils.parseAndTransform(_.str.trim(data.content), mailUtils.addLink);
+ body = this._generateEmojis(body);
+ var messageData = {
+ partner_ids: data.partner_ids,
+ body: body,
+ attachment_ids: data.attachment_ids,
+ };
+ if ('subject' in data) {
+ messageData.subject = data.subject;
+ }
+ return $.when(messageData);
+ },
+ /**
+ * @private
+ */
+ resetUnreadCounter: function () {
+ this._unreadCounter = 0;
+ this._warnNewUnreadCounter();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @abstract
+ * @private
+ * @return {$.Promise}
+ */
+ _fetchMessages: function () {
+ return $.when();
+ },
+ /**
+ * Replace character representations of emojis by their unicode
+ * representation in the provided HTML string. Note that the provided html
+ * string is altered by this function.
+ *
+ * @param {string} htmlString
+ * @returns {string}
+ */
+ _generateEmojis: function (htmlString) {
+ _.each(emojis, function (emoji) {
+ _.each(emoji.sources, function (source) {
+ var escapedSource = String(_.escape(source)).replace(/([.*+?=^!:${}()|[\]/\\])/g, '\\$1');
+ var regexp = new RegExp("(\\s|^)(" + escapedSource + ")(?=\\s|$)", 'g');
+ htmlString = htmlString.replace(regexp, '$1' + emoji.unicode);
+ });
+ });
+ return htmlString;
+ },
+ /**
+ * Warn other mail components that the window state of this thread has
+ * changed (it has become closed, folded, unfolded, detached, etc.)
+ *
+ * @private
+ * @param {Object} [options={}]
+ */
+ _warnUpdatedWindowState: function (options) {
+ options = options || {};
+ this.call('mail_service', 'updateThreadWindow', this.getID(), options);
+ },
+ /**
+ * Warn on the chat bus that the unread counter has been updated
+ *
+ * @private
+ */
+ _warnNewUnreadCounter: function () {
+ this.call('mail_service', 'getMailBus')
+ .trigger('update_thread_unread_counter', this);
+ },
+});
+
+return Thread;
+
+});
diff --git a/addons/mail/static/src/js/models/thread_with_cache.js b/addons/mail/static/src/js/models/thread_with_cache.js
new file mode 100644
index 00000000000..f25a19005f2
--- /dev/null
+++ b/addons/mail/static/src/js/models/thread_with_cache.js
@@ -0,0 +1,204 @@
+odoo.define('mail.model.ThreadWithCache', function (require) {
+"use strict";
+
+var Thread = require('mail.model.Thread');
+
+var session = require('web.session');
+
+/**
+ * This is a version of a thread that is handling a cache.
+ * Basically, this cache is an object indexed by stringified indexes.
+ * These indexes represent the domain of messages that match this domain.
+ *
+ * Any threads that are instances of this Class can be used with a search view,
+ * in order to make searches on messages in a thread.
+ */
+var ThreadWithCache = Thread.extend({
+ /**
+ * @override
+ * @param {integer} [data.seen_message_id=undefined]
+ */
+ init: function (parent, data) {
+ this._super.apply(this, arguments);
+
+ this._cache = {
+ '[]': {
+ allHistoryLoaded: false,
+ loaded: false,
+ messages: [],
+ }
+ };
+ this._lastSeenMessageID = data.seen_message_id;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add the message to this thread
+ *
+ * @override
+ * @private
+ * @param {mail.model.Message} message
+ * @param {Array} domain
+ */
+ addMessage: function (message, domain) {
+ var cache = this._getCache(domain);
+ var index = _.sortedIndex(cache.messages, message, function (msg) {
+ return msg.getID();
+ });
+ if (cache.messages[index] !== message) {
+ cache.messages.splice(index, 0, message);
+ }
+ },
+ /**
+ * Get the last seen message for a given thread
+ *
+ * @return {integer|null} last seen message ID (if any)
+ */
+ getLastSeenMessageID: function () {
+ return this._lastSeenMessageID || null;
+ },
+ /**
+ * @override
+ * @param {Array} [domain]
+ * @param {boolean} [loadMore=false]
+ */
+ getMessages: function (domain, loadMore) {
+ if (loadMore) {
+ return this._fetchMessages(domain, true);
+ }
+ var cache = this._getCache(domain);
+ if (cache.loaded) {
+ return $.when(cache.messages);
+ } else {
+ return this._fetchMessages(domain);
+ }
+ },
+ /**
+ * @private
+ * @return {boolean} true if there is at least a message, false otherwise
+ */
+ hasMessages: function () {
+ return !!(_.last(this._cache['[]'].messages));
+ },
+ /**
+ * Invalidate the caches of the thread
+ */
+ invalidateCaches: function () {
+ this._cache = _.pick(this._cache, '[]');
+ },
+ /**
+ * State whether all messages have been loaded or not
+ *
+ * @param {Array} domain
+ * @return {boolean}
+ */
+ isAllHistoryLoaded: function (domain) {
+ return this._getCache(domain).allHistoryLoaded;
+ },
+ /**
+ * Remove message with ID `messageID` from this thread
+ *
+ * This is equivalent to removing this message from all the caches
+ *
+ * @param {integer} messageID
+ */
+ removeMessage: function (messageID) {
+ _.each(this._cache, function (cache) {
+ cache.messages = _.reject(cache.messages, function (message) {
+ return message.getID() === messageID;
+ });
+ });
+ },
+ /**
+ * Set 'messageID' as the ID of the last message seen in this thread
+ *
+ * @param {integer} messageID
+ */
+ setLastSeenMessageID: function (messageID) {
+ this._lastSeenMessageID = messageID;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Gets messages from thread
+ *
+ * @override
+ * @private
+ * @param {Array} [pDomain] filter on the messages of the channel
+ * @param {boolean} [loadMore] Whether it should load more message
+ * @return {$.Promise<mail.model.Message[]>} resolved with list of messages
+ */
+ _fetchMessages: function (pDomain, loadMore) {
+ var self = this;
+
+ var domain = this._getThreadDomain();
+
+ var cache = this._getCache(pDomain);
+ if (pDomain) {
+ domain = domain.concat(pDomain || []);
+ }
+ if (loadMore) {
+ var minMessageID = cache.messages[0].getID();
+ domain = [['id', '<', minMessageID]].concat(domain);
+ }
+ return this._rpc({
+ model: 'mail.message',
+ method: 'message_fetch',
+ args: [domain],
+ kwargs: {
+ limit: this._FETCH_LIMIT,
+ context: session.user_context
+ },
+ }).then(function (messages) {
+ if (!cache.allHistoryLoaded) {
+ cache.allHistoryLoaded = messages.length < self._FETCH_LIMIT;
+ }
+ cache.loaded = true;
+ _.each(messages, function (message) {
+ self.call('mail_service', 'addMessage', message, {
+ silent: true,
+ domain: pDomain,
+ });
+ });
+ cache = self._getCache(pDomain || []);
+ return cache.messages;
+ });
+ },
+ /**
+ * Get thread content from the cache
+ * Useful to get cached messages.
+ *
+ * @private
+ * @param {Array} domain
+ * @return {Object|undefined}
+ */
+ _getCache: function (domain) {
+ var stringifiedDomain = JSON.stringify(domain || []);
+ if (!this._cache[stringifiedDomain]) {
+ this._cache[stringifiedDomain] = {
+ allHistoryLoaded: false,
+ loaded: false,
+ messages: [],
+ };
+ }
+ return this._cache[stringifiedDomain];
+ },
+ /**
+ * Get the domain to fetch all the messages in the current thread
+ *
+ * @abstract
+ * @private
+ * @returns {Array}
+ */
+ _getThreadDomain: function () {},
+});
+
+return ThreadWithCache;
+
+});