diff options
Diffstat (limited to 'addons/mail/static/src/js/models/threads')
| -rw-r--r-- | addons/mail/static/src/js/models/threads/channel.js | 442 | ||||
| -rw-r--r-- | addons/mail/static/src/js/models/threads/dm.js | 94 | ||||
| -rw-r--r-- | addons/mail/static/src/js/models/threads/document_thread.js | 317 | ||||
| -rw-r--r-- | addons/mail/static/src/js/models/threads/mailbox.js | 213 | ||||
| -rw-r--r-- | addons/mail/static/src/js/models/threads/thread.js | 446 | ||||
| -rw-r--r-- | addons/mail/static/src/js/models/threads/thread_with_cache.js | 204 |
6 files changed, 1716 insertions, 0 deletions
diff --git a/addons/mail/static/src/js/models/threads/channel.js b/addons/mail/static/src/js/models/threads/channel.js new file mode 100644 index 00000000000..e63cab2f907 --- /dev/null +++ b/addons/mail/static/src/js/models/threads/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/threads/dm.js b/addons/mail/static/src/js/models/threads/dm.js new file mode 100644 index 00000000000..cd08dd7526d --- /dev/null +++ b/addons/mail/static/src/js/models/threads/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/threads/document_thread.js b/addons/mail/static/src/js/models/threads/document_thread.js new file mode 100644 index 00000000000..92cc26e845b --- /dev/null +++ b/addons/mail/static/src/js/models/threads/document_thread.js @@ -0,0 +1,317 @@ +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()); + } + }, + /** + * Overrides to store the thread's state in the LocalStorage, so that it is + * shared between tabs, and restored on F5. + * + * @override + */ + close: function () { + this._super.apply(this, arguments); + this.call('mail_service', 'updateDocumentThreadState', this._id, { + name: this.getName(), + windowState: 'closed', + }); + }, + /** + * Overrides to store the thread's state in the LocalStorage, so that it is + * shared between tabs, and restored on F5. + * + * @override + */ + detach: function () { + this._super.apply(this, arguments); + var windowState = this._folded ? 'folded' : 'open'; + this.call('mail_service', 'updateDocumentThreadState', this._id, { + name: this.getName(), + windowState: windowState, + }); + }, + /** + * Overrides to store the thread's state in the LocalStorage, so that it is + * shared between tabs, and restored on F5. + * + * @override + */ + fold: function () { + this._super.apply(this, arguments); + var windowState = this._folded ? 'folded' : 'open'; + this.call('mail_service', 'updateDocumentThreadState', this._id, { + name: this.getName(), + windowState: windowState, + }); + }, + /** + * 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 || "mail.mt_comment", + 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/threads/mailbox.js b/addons/mail/static/src/js/models/threads/mailbox.js new file mode 100644 index 00000000000..bcb5ae609b7 --- /dev/null +++ b/addons/mail/static/src/js/models/threads/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/threads/thread.js b/addons/mail/static/src/js/models/threads/thread.js new file mode 100644 index 00000000000..de2c6628d67 --- /dev/null +++ b/addons/mail/static/src/js/models/threads/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/threads/thread_with_cache.js b/addons/mail/static/src/js/models/threads/thread_with_cache.js new file mode 100644 index 00000000000..f25a19005f2 --- /dev/null +++ b/addons/mail/static/src/js/models/threads/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; + +}); |
