From f2302dfb27dd7f6a0dee7c65ab75b62a01706264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Sat, 11 Aug 2018 20:30:51 +0200 Subject: [PATCH] [REF][FIX] mail, im_livechat: various improvements (JS) This commit makes a lot of small improvements of the JS of mail and related modules. A. Mail Manager --------------- im_support: - [REF] Slightly more robust way to determine a support message, so that it more reliably falls back on default kind of message (i.e. support channel UUID should be clearly set). mail: - [REF] Improved method names for readability purposes. Some examples: - `fetchFromServer` becomes `fetchMailStateFromServer` - `_initialize{*}` becomes `_update{*}FromServer` - `_isModerator` becomes `_isMyselfModerator` - [REF] Use 'template' pattern design to make Include on Mail Manager much more robust. In particular, the flow is always as follow: 1. Initialize internal state. 2. Listen on buses. 3. Fetch mail data from server. - [REF] Improved workflow for thread window workflow (Shorter & more readable). B. Mail Tests ------------- - [REF] Thread Window Tests crashes with clean message when there are some thread windows open before running the tests. C. (Model) Message ------------------ - [REF] Improved method names for readability purposes (e.g. `isAuthor` becomes `isMyselfAuthor`). D. (Model) Thread ----------------- - [REF] Improved `init` parameters on each kind of thread. - [REF] 'Thread With Cache' has been renamed to 'Searchable Thread': it embraces all kinds of threads that can be used with the search view. - [REF] 'DM' has been renamed to 'DM Chat' (for clarity). - [IMP] New Livechat model in the backend. - [REF] No more direct instances of the Channel class: public and private channels are now 'Multi-User' channels, while DM Chat and Livechat are 'Two-User' channels: This change has been made so that instances of classes always have direct classes as "leaf" in the Class modeling. It eases making changes to a certain model without being too constrained by another model. - [REF] As a consequence of above changed, the method `isChat` has been renamed to `isTwoUserThread`. This is to avoid confusion with the terminology 'chat', which is more like a shortcut for 'quick real- time conversation'. In other words, the property of 'chat' depends of the way to interact on a thread, not of the intrinsic type of the thread. Still, some parts have kept the terminology 'chat', such as in the systray messagin menu. - [IMP] Added `message_added` and `message_posted` hooks, which may be useful to easily hook on those events. E. Thread Widget ---------------- - [REF] Now uses a `mail.model.AbstractThread` for the rendering of a thread, instead of a list of `{mail.model.AbstractMessage}`. - [FIX] Stick thread scroll height to bottom on new rendering, if it was previously at the bottom of the thread. - [REF] Option ORDER has its meaning changed: it now refers to the chronological order of messages in the thread, in which DESC means 'from top to bottom', while ASC means 'from bottom to top'. - [REF] Improved doc on templates, in particular with the option `ORDER`. F. Thread Window ---------------- - [REF] Moved more logic to thread windows, which were previously in Mail Window Manager. - [REF] Now hides the thread widget attribute. So instead of `threadWindow.threadWidget.isAtBottom()`, it uses `threadWindow.isAtBottom()`. - [FIX] New received messages when not having focus on Odoo tab should always make the corresponding thread windows "passive" (i.e. windows that keep threads unread until click on them). - [FIX] Blank thread window (i.e. 'New message' TW) correctly opens Discuss with 'Inbox' when clicking on 'Expand' Button in the title - [FIX] Do not display the composer for channels with property 'mass-mailing' set in thread windows. G. (Website) Livechat --------------------- - [REF] Some `trigger` have been turned into `trigger_up`. - [REF] Some logic has been moved to models (e.g. `{im_livechat.model.WebsiteLivechat}`), from `{im_livechat.im_livechat.LivechatButton}`. - [FIX] New messages should be received in real-time. The longpolling was broken with a recent refactoring on bus service (6448420c5dd160470e465dee7729d19d8d5e7bab). --- addons/bus/static/src/js/crosstab_bus.js | 1 - .../im_livechat/static/src/js/im_livechat.js | 95 ++-- .../static/src/js/models/website_livechat.js | 67 ++- .../src/js/models/website_livechat_message.js | 2 +- .../static/src/js/website_livechat_window.js | 17 +- .../im_support/static/src/js/mail_manager.js | 9 +- .../static/src/js/support_channel.js | 96 ++-- .../im_support/static/tests/systray_tests.js | 2 +- addons/mail/static/src/js/activity.js | 11 +- addons/mail/static/src/js/chatter.js | 6 +- addons/mail/static/src/js/discuss.js | 58 +-- addons/mail/static/src/js/discuss_mobile.js | 5 +- .../js/models/messages/abstract_message.js | 4 +- .../static/src/js/models/messages/message.js | 16 +- .../src/js/models/threads/abstract_thread.js | 88 +++- .../static/src/js/models/threads/channel.js | 168 +++---- .../js/models/threads/{dm.js => dm_chat.js} | 41 +- .../src/js/models/threads/document_thread.js | 156 ++++--- .../static/src/js/models/threads/livechat.js | 57 +++ .../static/src/js/models/threads/mailbox.js | 4 +- .../js/models/threads/multi_user_channel.js | 56 +++ ...ead_with_cache.js => searchable_thread.js} | 41 +- .../static/src/js/models/threads/thread.js | 161 +++---- .../src/js/models/threads/two_user_channel.js | 32 ++ .../static/src/js/services/mail_manager.js | 431 ++++++++++-------- .../js/services/mail_notification_manager.js | 45 +- .../src/js/services/mail_window_manager.js | 190 ++++---- .../src/js/systray/systray_activity_menu.js | 2 - .../src/js/systray/systray_messaging_menu.js | 3 + addons/mail/static/src/js/thread_field.js | 2 +- addons/mail/static/src/js/thread_widget.js | 38 +- .../thread_windows/abstract_thread_window.js | 79 +++- .../src/js/thread_windows/thread_window.js | 141 ++++-- .../src/scss/abstract_thread_window.scss | 1 + addons/mail/static/src/scss/discuss.scss | 1 - addons/mail/static/src/scss/thread.scss | 31 ++ .../static/src/xml/abstract_thread_window.xml | 2 +- addons/mail/static/src/xml/discuss.xml | 14 +- addons/mail/static/src/xml/thread.xml | 99 +++- .../mail/static/tests/discuss_mobile_tests.js | 6 +- .../static/tests/discuss_moderation_tests.js | 10 +- addons/mail/static/tests/discuss_tests.js | 26 +- .../tests/document_thread_window_tests.js | 2 +- .../mail/static/tests/helpers/mock_server.js | 5 +- .../mail/static/tests/helpers/test_utils.js | 2 +- .../mail/static/tests/thread_window_tests.js | 19 +- addons/mail/views/mail_templates.xml | 10 +- 47 files changed, 1422 insertions(+), 930 deletions(-) rename addons/mail/static/src/js/models/threads/{dm.js => dm_chat.js} (66%) create mode 100644 addons/mail/static/src/js/models/threads/livechat.js create mode 100644 addons/mail/static/src/js/models/threads/multi_user_channel.js rename addons/mail/static/src/js/models/threads/{thread_with_cache.js => searchable_thread.js} (96%) create mode 100644 addons/mail/static/src/js/models/threads/two_user_channel.js diff --git a/addons/bus/static/src/js/crosstab_bus.js b/addons/bus/static/src/js/crosstab_bus.js index c0402dcac10..13e6cdb98cc 100644 --- a/addons/bus/static/src/js/crosstab_bus.js +++ b/addons/bus/static/src/js/crosstab_bus.js @@ -2,7 +2,6 @@ odoo.define('bus.CrossTab', function (require) { "use strict"; var Longpolling = require('bus.Longpolling'); -var session = require('web.session'); /** diff --git a/addons/im_livechat/static/src/js/im_livechat.js b/addons/im_livechat/static/src/js/im_livechat.js index 3179986e88a..2a637f4b7d6 100644 --- a/addons/im_livechat/static/src/js/im_livechat.js +++ b/addons/im_livechat/static/src/js/im_livechat.js @@ -44,6 +44,12 @@ if (!_.contains(urlHistory, page)) { var LivechatButton = Widget.extend({ className:'openerp o_livechat_button d-print-none', + custom_events: { + 'close_chat_window': '_onCloseChatWindow', + 'post_message_chat_window': '_onPostMessageChatWindow', + 'save_chat_window': '_onSaveChatWindow', + 'updated_unread_counter': '_onUpdatedUnreadCounter', + }, events: { 'click': '_openChat' }, @@ -112,7 +118,7 @@ var LivechatButton = Widget.extend({ * @param {Object} [options={}] */ _addMessage: function (data, options) { - options = _.extend({}, options, { + options = _.extend({}, this.options, options, { serverURL: this._serverURL, }); var message = new WebsiteLivechatMessage(this, data, options); @@ -130,7 +136,7 @@ var LivechatButton = Widget.extend({ this._chatWindow.$('.o_thread_composer input').prop('disabled', true); var feedback = new Feedback(this, this._livechat); - feedback.replace(this._chatWindow.threadWidget.$el); + this._chatWindow.replaceContentWith(feedback); feedback.on('send_message', this, this._sendMessage); feedback.on('feedback_sent', this, this._closeChat); @@ -159,7 +165,7 @@ var LivechatButton = Widget.extend({ } else { // normal message this._addMessage(notification[1]); this._renderMessages(); - if (this._chatWindow.isFolded() || !this._chatWindow.threadWidget.isAtBottom()) { + if (this._chatWindow.isFolded() || !this._chatWindow.isAtBottom()) { this._livechat.incrementUnreadCounter(); } } @@ -204,13 +210,16 @@ var LivechatButton = Widget.extend({ if (!livechatData || !livechatData.operator_pid) { alert(_t("None of our collaborators seems to be available, please try again later.")); } else { - self._livechat = new WebsiteLivechat(livechatData); + self._livechat = new WebsiteLivechat({ + parent: self, + data: livechatData + }); self._openChatWindow(); self._sendWelcomeMessage(); self._renderMessages(); - self.busBus.addChannel(self._livechat.getUUID()); - self.busBus.startPolling(); + self.call('bus_service', 'addChannel', self._livechat.getUUID()); + self.call('bus_service', 'startPolling'); utils.set_cookie('im_livechat_session', JSON.stringify(self._livechat.toData()), 60*60); utils.set_cookie('im_livechat_auto_popup', JSON.stringify(false), 60*60); @@ -235,41 +244,16 @@ var LivechatButton = Widget.extend({ self._chatWindow.$el.css(cssProps); self.$el.hide(); }); - this._chatWindow.on('close', this, function () { - var input_disabled = this._chatWindow.$('.o_thread_composer input').prop('disabled'); - var ask_fb = !input_disabled && _.find(this._messages, function (message) { - return message.getID() !== '_welcome'; - }); - if (ask_fb) { - this._chatWindow.toggleFold(false); - this._askFeedback(); - } else { - this._closeChat(); - } - }); - this._chatWindow.on('post_message', this, function (message) { - self._sendMessage(message).fail(function (error, e) { - e.preventDefault(); - return self._sendMessage(message); // try again just in case - }); - }); - this._chatWindow.on('save_chat', this, this._onSaveChat); - this._chatWindow.threadWidget.$el.on('scroll', null, _.debounce(function () { - if (self._chatWindow.threadWidget.isAtBottom()) { - self._livechat.resetUnreadCounter(); - self._chatWindow.renderHeader(); - } - }, 100)); }, /** * @private */ _renderMessages: function () { - var shouldScroll = !this._chatWindow.isFolded() && this._chatWindow.threadWidget.isAtBottom(); + var shouldScroll = !this._chatWindow.isFolded() && this._chatWindow.isAtBottom(); this._livechat.setMessages(this._messages); this._chatWindow.render(); if (shouldScroll) { - this._chatWindow.threadWidget.scrollToBottom(); + this._chatWindow.scrollToBottom(); } }, /** @@ -282,7 +266,7 @@ var LivechatButton = Widget.extend({ return session .rpc('/mail/chat_post', {uuid: this._livechat.getUUID(), message_content: message.content}) .then(function () { - self._chatWindow.threadWidget.scrollToBottom(); + self._chatWindow.scrollToBottom(); }); }, /** @@ -306,6 +290,23 @@ var LivechatButton = Widget.extend({ // Handlers //-------------------------------------------------------------------------- + /** + * @private + * @param {OdooEvent} ev + */ + _onCloseChatWindow: function (ev) { + ev.stopPropagation(); + var isComposerDisabled = this._chatWindow.$('.o_thread_composer input').prop('disabled'); + var shouldAskFeedback = !isComposerDisabled && _.find(this._messages, function (message) { + return message.getID() !== '_welcome'; + }); + if (shouldAskFeedback) { + this._chatWindow.toggleFold(false); + this._askFeedback(); + } else { + this._closeChat(); + } + }, /** * @private * @param {Array[]} notifications @@ -318,10 +319,34 @@ var LivechatButton = Widget.extend({ }, /** * @private + * @param {OdooEvent} ev + * @param {Object} ev.data.messageData */ - _onSaveChat: function () { + _onPostMessageChatWindow: function (ev) { + ev.stopPropagation(); + var self = this; + var messageData = ev.data.messageData; + this._sendMessage(messageData).fail(function (error, e) { + e.preventDefault(); + return self._sendMessage(messageData); // try again just in case + }); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onSaveChatWindow: function (ev) { + ev.stopPropagation(); utils.set_cookie('im_livechat_session', JSON.stringify(this._livechat.toData()), 60*60); }, + /** + * @private + * @param {OdooEvent} ev + */ + _onUpdatedUnreadCounter: function (ev) { + ev.stopPropagation(); + this._chatWindow.renderHeader(); + }, }); /* diff --git a/addons/im_livechat/static/src/js/models/website_livechat.js b/addons/im_livechat/static/src/js/models/website_livechat.js index a21f2ee2f49..758f90b302c 100644 --- a/addons/im_livechat/static/src/js/models/website_livechat.js +++ b/addons/im_livechat/static/src/js/models/website_livechat.js @@ -3,6 +3,8 @@ odoo.define('im_livechat.model.WebsiteLivechat', function (require) { var AbstractThread = require('mail.model.AbstractThread'); +var session = require('web.session'); + /** * Thread model that represents a livechat on the website-side. This livechat * is not linked to the mail service. @@ -12,34 +14,37 @@ var WebsiteLivechat = AbstractThread.extend({ /** * @override * @private - * @param {Object} livechatData - * @param {boolean} [livechatData.folded] states whether the livechat is + * @param {Object} params + * @param {Object} params.data + * @param {string} params.data.anonymous_name + * @param {boolean} [params.data.folded] states whether the livechat is * folded or not. It is considered only if this is defined and it is a * boolean. - * @param {integer} livechatData.id the ID of this livechat. - * @param {integer} [livechatData.message_unread_counter] the - * unread counter of this livechat. - * @param {Array} livechatData.operator_pid - * @param {string} livechatData.name the name of this livechat. - * @param {string} [livechatData.state] if 'folded', the livechat is folded. + * @param {integer} params.data.id the ID of this livechat. + * @param {integer} [params.data.message_unread_counter] the unread counter + * of this livechat. + * @param {Array} params.data.operator_pid + * @param {string} params.data.name the name of this livechat. + * @param {string} [params.data.state] if 'folded', the livechat is folded. * This is ignored if `folded` is provided and is a boolean value. - * @param {string} livechatData.uuid the UUID of this livechat. + * @param {string} params.data.uuid the UUID of this livechat. + * @param {im_livechat.im_livechat.LivechatButton} params.parent */ - init: function (livechatData) { - var params = { data: livechatData }; - this._super.call(this, params); + init: function (params) { + this._super.apply(this, arguments); - this._operatorPID = livechatData.operator_pid; - this._uuid = livechatData.uuid; + this._members = []; + this._operatorPID = params.data.operator_pid; + this._uuid = params.data.uuid; - if (livechatData.message_unread_counter !== undefined) { - this._unreadCounter = livechatData.message_unread_counter; + if (params.data.message_unread_counter !== undefined) { + this._unreadCounter = params.data.message_unread_counter; } - if (_.isBoolean(livechatData.folded)) { - this._folded = livechatData.folded; + if (_.isBoolean(params.data.folded)) { + this._folded = params.data.folded; } else { - this._folded = livechatData.state === 'folded'; + this._folded = params.data.state === 'folded'; } }, @@ -66,6 +71,16 @@ var WebsiteLivechat = AbstractThread.extend({ getUUID: function () { return this._uuid; }, + /** + * Increments the unread counter of this livechat by 1 unit. + * + * Note: this public method makes sense because the management of messages + * for website livechat is external. This method should be dropped when + * this class handles messages by itself. + */ + incrementUnreadCounter: function () { + this._incrementUnreadCounter(); + }, /** * AKU: hack for the moment * @@ -87,6 +102,20 @@ var WebsiteLivechat = AbstractThread.extend({ uuid: this.getUUID(), }; }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Warn that the unread counter has been updated on this livechat + * + * @override + * @private + */ + _warnUpdatedUnreadCounter: function () { + this.trigger_up('updated_unread_counter'); + }, }); return WebsiteLivechat; diff --git a/addons/im_livechat/static/src/js/models/website_livechat_message.js b/addons/im_livechat/static/src/js/models/website_livechat_message.js index f8854ba7655..9728c78e270 100644 --- a/addons/im_livechat/static/src/js/models/website_livechat_message.js +++ b/addons/im_livechat/static/src/js/models/website_livechat_message.js @@ -13,7 +13,7 @@ var AbstractMessage = require('mail.model.AbstractMessage'); var WebsiteLivechatMessage = AbstractMessage.extend({ /** - * @param {Widget} parent + * @param {im_livechat.im_livechat.LivechatButton} parent * @param {Object} data * @param {Object} options * @param {string} options.serverURL diff --git a/addons/im_livechat/static/src/js/website_livechat_window.js b/addons/im_livechat/static/src/js/website_livechat_window.js index 4e34be60557..ea5b11f5417 100644 --- a/addons/im_livechat/static/src/js/website_livechat_window.js +++ b/addons/im_livechat/static/src/js/website_livechat_window.js @@ -11,7 +11,7 @@ var AbstractThreadWindow = require('mail.AbstractThreadWindow'); var LivechatWindow = AbstractThreadWindow.extend({ /** * @override - * @param parent + * @param {im_livechat.im_livechat.LivechatButton} parent * @param {im_livechat.model.WebsiteLivechat} thread */ init: function (parent, thread) { @@ -28,7 +28,15 @@ var LivechatWindow = AbstractThreadWindow.extend({ * @override */ close: function () { - this.trigger('close'); + this.trigger_up('close_chat_window'); + }, + /** + * Replace the thread content with provided new content + * + * @param {$.Element} $element + */ + replaceContentWith: function ($element) { + $element.replace(this._threadWidget.$el); }, /** * Warn the parent widget (LivechatButton) @@ -38,7 +46,7 @@ var LivechatWindow = AbstractThreadWindow.extend({ */ toggleFold: function () { this._super.apply(this, arguments); - this.trigger('save_chat'); + this.trigger_up('save_chat_window'); this.updateVisualFoldState(); }, @@ -52,7 +60,8 @@ var LivechatWindow = AbstractThreadWindow.extend({ * @param {Object} messageData */ _postMessage: function (messageData) { - this.trigger('post_message', messageData); + this.trigger_up('post_message_chat_window', { messageData: messageData }); + this._super.apply(this, arguments); }, }); diff --git a/addons/im_support/static/src/js/mail_manager.js b/addons/im_support/static/src/js/mail_manager.js index 193f78d692b..6a44e87ed8d 100644 --- a/addons/im_support/static/src/js/mail_manager.js +++ b/addons/im_support/static/src/js/mail_manager.js @@ -186,7 +186,10 @@ MailManager.include({ * @override */ _makeMessage: function (data) { - if (_.contains(data.channel_ids, this.supportChannelUUID)) { + if ( + this.supportChannelUUID && + _.contains(data.channel_ids, this.supportChannelUUID) + ) { return new SupportMessage(this, data, this._emojis); } return this._super.apply(this, arguments); @@ -239,10 +242,10 @@ MailManager.include({ // ignore history requests return; } - var message = _.extend(notification[1], { + var messageData = _.extend(notification[1], { channel_ids: [self.supportChannelUUID], }); - self._handleChannelNotification(message); + self._handleChannelNotification({ data: messageData }); }); }, }); diff --git a/addons/im_support/static/src/js/support_channel.js b/addons/im_support/static/src/js/support_channel.js index 4fd66bdfc81..3147cc55a94 100644 --- a/addons/im_support/static/src/js/support_channel.js +++ b/addons/im_support/static/src/js/support_channel.js @@ -3,7 +3,7 @@ odoo.define('im_support.SupportChannel', function (require) { var supportSession = require('im_support.SupportSession'); -var ThreadWithCache = require('mail.model.ThreadWithCache'); +var SearchableThread = require('mail.model.SearchableThread'); var core = require('web.core'); var session = require('web.session'); @@ -17,9 +17,9 @@ var _t = core._t; * communicate from their respective 'backend' access. * * FIXME: it should inherit from mail.model.Channel, not from - * mail.model.ThreadWithCache + * mail.model.SearchableThread */ -var SupportChannel = ThreadWithCache.extend({ +var SupportChannel = SearchableThread.extend({ /** * @override @@ -51,7 +51,7 @@ var SupportChannel = ThreadWithCache.extend({ this._super.apply(this, arguments); // force stuff that should probably be in Thread (or at least - // ThreadWithCache), but that are currently in Channel + // SearchableThread), but that are currently in Channel this._detached = data.is_minimized; this._folded = data.state === 'folded'; this._uuid = data.uuid; @@ -100,6 +100,22 @@ var SupportChannel = ThreadWithCache.extend({ var value = folded ? 'folded' : 'open'; this.call('mail_service', 'updateSupportChannelState', value); }, + /** + * FIXME: this override is necessary just because the support channel is + * considered as a channel, even though it does not inherit from + * mail.model.Channel. + * + * @returns {integer} + */ + getNeedactionCounter: function () { + return 0; + }, + /** + * @return {string} uuid of this channel + */ + getUUID: function () { + return this._uuid; + }, /** * FIXME: this method is necessary just because the support channel is * considered as a channel, even though it does not inherit from @@ -128,44 +144,31 @@ var SupportChannel = ThreadWithCache.extend({ return true; }, /** - * Support channels are not considered as chat, so they are not displayed - * in the 'chat' filter of the systray messaging menu. + * Called when fold or detach (or both) status have changed on the support + * channel. * - * @override - * @returns {boolean} - */ - isChat: function () { - return false; - }, - /** - * FIXME: this override is necessary just because the support channel is - * considered as a channel, even though it does not inherit from - * mail.model.Channel. + * Note: this is partially a hack due to support channel not being a + * channel. Also, the support channel uses another way to handle its window + * state, by means of the local storage. * - * @returns {integer} + * @param {Object} params + * @param {boolean} [params.folded] + * @param {boolean} [params.detached] */ - getNeedactionCounter: function () { - return 0; - }, - /** - * @return {string} uuid of this channel - */ - getUUID: function () { - return this._uuid; - }, - /** - * Posts the message on the Support server. - * - * @override - * @return {$.Promise} - */ - postMessage: function (data) { - // ensure that the poll is active before posting the message - this.call('mail_service', 'startPollingSupport'); - return supportSession.rpc('/odoo_im_support/chat_post', { - uuid: this._supportChannelUUID, - message_content: data.content, - }); + updateWindowState: function (params) { + if ('detached' in params) { + this._detached = params.detached; + } + if ('folded' in params) { + this._folded = params.folded; + } + + if (!this._detached) { + this.call('mail_service', 'updateSupportChannelState', 'closed'); + } else { + var value = this._folded ? 'folded' : 'open'; + this.call('mail_service', 'updateSupportChannelState', value); + } }, //-------------------------------------------------------------------------- @@ -241,6 +244,21 @@ var SupportChannel = ThreadWithCache.extend({ return channelCache.messages; }); }, + /** + * Posts the message on the Support server. + * + * @override + * @private + * @return {$.Promise} + */ + _postMessage: function (data) { + // ensure that the poll is active before posting the message + this.call('mail_service', 'startPollingSupport'); + return supportSession.rpc('/odoo_im_support/chat_post', { + uuid: this._supportChannelUUID, + message_content: data.content, + }); + }, }); return SupportChannel; diff --git a/addons/im_support/static/tests/systray_tests.js b/addons/im_support/static/tests/systray_tests.js index 09a8f600642..3bf05417d7e 100644 --- a/addons/im_support/static/tests/systray_tests.js +++ b/addons/im_support/static/tests/systray_tests.js @@ -315,7 +315,7 @@ QUnit.test('receive messages in the Support channel', function (assert) { body: 'A message', channel_ids: [supportChannelID], }; - var notification = [[false, 'mail.channel'], data]; + var notification = [[false, 'mail.channel', 1], data]; messagingMenu.call('support_bus_service', 'trigger', 'notification', [notification]); assert.strictEqual($('.o_thread_window .o_thread_message').length, 1, diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js index 0a25fcfe194..8f4c6b1e7c7 100644 --- a/addons/mail/static/src/js/activity.js +++ b/addons/mail/static/src/js/activity.js @@ -117,7 +117,7 @@ var AbstractActivityField = AbstractField.extend({ * @param {integer} params.activityID * @param {string} params.feedback */ - _markActivityDone: function(params) { + _markActivityDone: function (params) { var activityID = params.activityID; var feedback = params.feedback; @@ -134,7 +134,7 @@ var AbstractActivityField = AbstractField.extend({ * @param {integer} params.activityID * @param {string} params.feedback */ - _markActivityDoneAndScheduleNext: function(params) { + _markActivityDoneAndScheduleNext: function (params) { var activityID = params.activityID; var feedback = params.feedback; var self = this; @@ -145,8 +145,7 @@ var AbstractActivityField = AbstractField.extend({ kwargs: {feedback: feedback}, context: this.record.getContext(), }).then( - function(rslt_action) { - console.log(rslt_action); + function (rslt_action) { self.do_action(rslt_action, { on_close: function () { self.trigger_up('reload'); @@ -352,7 +351,7 @@ var Activity = AbstractActivityField.extend({ force_email: true, }, }; - return this.do_action(action, { on_close: function(){}}); + return this.do_action(action, { on_close: function () {} }); }, /** * @private @@ -650,7 +649,7 @@ var KanbanActivity = AbstractActivityField.extend({ force_email: true, }, }; - return this.do_action(action, { on_close: function(){}}); + return this.do_action(action, { on_close: function () {} }); }, /** * @private diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js index ca71f8a75ad..6ec365ceda4 100644 --- a/addons/mail/static/src/js/chatter.js +++ b/addons/mail/static/src/js/chatter.js @@ -39,9 +39,9 @@ var Chatter = Widget.extend({ * @param {widget} parent * @param {Object} record * @param {Object} mailFields - * @param {boolean} [mailFields.mail_activity] - * @param {boolean} [mailFields.mail_followers] - * @param {boolean} [mailFields.mail_thread] + * @param {string} [mailFields.mail_activity] + * @param {string} [mailFields.mail_followers] + * @param {string} [mailFields.mail_thread] */ init: function (parent, record, mailFields, options) { this._super.apply(this, arguments); diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js index 80b61d983bf..2ae175666b5 100644 --- a/addons/mail/static/src/js/discuss.js +++ b/addons/mail/static/src/js/discuss.js @@ -195,6 +195,11 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { /** * @override + * @param {Object} [options] + // * @param {boolean} [options.phantomjs=false] if set, rendering of non-empty + // * thread will use 'block' display instead of 'flex', because phantomjs + // * sucks with flexbox... TODO: remove this option when we no longer use + // * phantomJS */ init: function (parent, action, options) { this._super.apply(this, arguments); @@ -445,15 +450,12 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { !this._thread.isMassMailing(), displayEmptyThread: !this._thread.hasMessages() && !this.domain.length, displayNoMatchFound: !this._thread.hasMessages() && this.domain.length, - displaySubjectOnMessages: - ( - this._thread.getType() !== 'mailbox' && - this._thread.isMassMailing() - ) || + displaySubjectOnMessages: this._thread.isMassMailing() || this._thread.getID() === 'mailbox_inbox' || this._thread.getID() === 'mailbox_moderation', displayEmailIcons: false, displayReplyIcons: true, + displayBottomThreadFreeSpace: true, }; }, /** @@ -495,8 +497,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { (this._threadWidget.el.clientHeight === this._threadWidget.el.scrollHeight) && !this._thread.isAllHistoryLoaded(this.domain); if (loadMoreMessages) { - return this._loadMoreMessages() - .then(this._loadEnoughMessages.bind(this)); + return this._loadMoreMessages().then(this._loadEnoughMessages.bind(this)); } }, /** @@ -520,8 +521,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { self._thread, self._getThreadRenderingOptions() ); - offset += dom.getPosition(document.querySelector(oldestMessageSelector) - ).top; + offset += dom.getPosition(document.querySelector(oldestMessageSelector)).top; self._threadWidget.scrollToPosition(offset); }); }, @@ -558,7 +558,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * * @private * @param {JQuery} $input the input to prepare - * @param {string} type the type of thread to create ('dm', 'public' or + * @param {string} type the type of thread to create ('dm_chat', 'public' or * 'private' channel) */ _prepareAddThreadInput: function ($input, type) { @@ -569,11 +569,11 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { self._lastSearchVal = _.escape(request.term); self._searchChannel(self._lastSearchVal).done(function (result){ result.push({ - 'label': _.str.sprintf( + label: _.str.sprintf( '' + _t("Create %s") + '', '"#' + self._lastSearchVal + '"' ), - 'value': '_create', + value: '_create', }); response(result); }); @@ -599,7 +599,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { self.call('mail_service', 'createChannel', name, 'private'); } }); - } else if (type === 'dm') { + } else if (type === 'dm_chat') { $input.autocomplete({ source: function (request, response) { self._lastSearchVal = _.escape(request.term); @@ -607,11 +607,11 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { }, select: function (ev, ui) { var partnerID = ui.item.id; - var dm = self.call('mail_service', 'getDmFromPartnerID', partnerID); - if (dm) { - self._setThread(dm.getID()); + var dmChat = self.call('mail_service', 'getDMChatFromPartnerID', partnerID); + if (dmChat) { + self._setThread(dmChat.getID()); } else { - self.call('mail_service', 'createChannel', partnerID, 'dm'); + self.call('mail_service', 'createChannel', partnerID, 'dm_chat'); } // clear the input $(this).val(''); @@ -693,7 +693,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { needactionCounter: inbox.getMailboxCounter(), starredCounter: starred.getMailboxCounter(), moderationCounter: moderation ? moderation.getMailboxCounter() : 0, - isModerator: this.call('mail_service', 'isModerator'), + isMyselfModerator: this.call('mail_service', 'isMyselfModerator'), })); return $sidebar; }, @@ -919,7 +919,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { */ _unselectMessage: function () { this._basicComposer.do_toggle(this._thread.getType() !== 'mailbox' && !this._thread.isMassMailing()); - this._extendedComposer.do_toggle(this._thread.getType() !== 'mailbox' && !!this._thread.isMassMailing()); + this._extendedComposer.do_toggle(this._thread.isMassMailing()); if (!config.device.isMobile) { var composer = this._thread.getType() !== 'mailbox' && this._thread.isMassMailing() ? @@ -959,7 +959,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { this._thread.getID() === 'mailbox_moderation' || ( this._thread.isModerated() && - this._thread.isModerator() + this._thread.isMyselfModerator() ) ) { this._updateModerationButtons(); @@ -985,7 +985,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @param {mail.model.Thread} thread */ _updateControlPanelButtons: function (thread) { - // Hide 'unsubscribe' button in state channels and DM and channels with group-based subscription + // Hide 'unsubscribe' button in state channels and DM chats and channels with group-based subscription this.$buttons .find('.o_mail_discuss_button_invite, .o_mail_discuss_button_settings') .toggle(thread.getType() !== 'dm' && thread.getType() !== 'mailbox'); @@ -999,11 +999,11 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { .removeClass('o_hidden'); this.$buttons .find('.o_mail_discuss_button_select_all') - .toggle((thread.isModerated() && thread.isModerator()) || thread.getID() === 'mailbox_moderation') + .toggle((thread.isModerated() && thread.isMyselfModerator()) || thread.getID() === 'mailbox_moderation') .removeClass('o_hidden'); this.$buttons .find('.o_mail_discuss_button_unselect_all') - .toggle((thread.isModerated() && thread.isModerator()) || thread.getID() === 'mailbox_moderation') + .toggle((thread.isModerated() && thread.isMyselfModerator()) || thread.getID() === 'mailbox_moderation') .removeClass('o_hidden'); this.$buttons.find('.o_mail_discuss_button_moderate_all').hide(); @@ -1076,7 +1076,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { }); this.$('.o_mail_discuss_sidebar').html($sidebar.contents()); - _.each(['dm', 'public', 'private'], function (type) { + _.each(['dm_chat', 'public', 'private'], function (type) { var $input = self.$('.o_mail_add_thread[data-type=' + type + '] input'); self._prepareAddThreadInput($input, type); }); @@ -1153,16 +1153,16 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { /** * Called when someone asks discuss whether the bottom of `thread` is * visible or not. An object `query` is provided in order to reponse on the - * key `isDisplayed`. + * key `isVisible`. * * @private * @param {mail.model.Thread} thread * @param {Object} query - * @param {boolean} query.isBottomVisible the response to provide on whether - * the thread is visible in discuss. + * @param {boolean} query.isVisible the response to provide on whether the + * bottom of the thread is visible in discuss. */ _onIsThreadBottomVisible: function (thread, query) { - query.isBottomVisible = query.isBottomVisible || + query.isVisible = query.isVisible || ( thread.getID() === this._thread.getID() && this._threadWidget.isAtBottom() @@ -1389,7 +1389,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { ev.stopPropagation(); var channelID = $(ev.target).data('thread-id'); var channel = this.call('mail_service', 'getChannel', channelID); - if (channel.isAdministrator()) { + if (channel.isMyselfAdministrator()) { this._askConfirmationAdminUnsubscribe(channel); } else { channel.unsubscribe(); diff --git a/addons/mail/static/src/js/discuss_mobile.js b/addons/mail/static/src/js/discuss_mobile.js index 8e3f0f86c22..fda74540688 100644 --- a/addons/mail/static/src/js/discuss_mobile.js +++ b/addons/mail/static/src/js/discuss_mobile.js @@ -73,7 +73,7 @@ Discuss.include({ _renderButtons: function () { var self = this; this._super.apply(this, arguments); - _.each(['dm', 'public', 'private'], function (type) { + _.each(['dm_chat', 'public', 'private'], function (type) { var selector = '.o_mail_discuss_button_' + type; self.$buttons.on('click', selector, self._onAddThread.bind(self)); }); @@ -106,6 +106,7 @@ Discuss.include({ */ _setThread: function (threadID) { var thread = this.call('mail_service', 'getThread', threadID); + this._thread = thread; if (thread.getType() !== 'mailbox') { this.call('mail_service', 'openThreadWindow', threadID); return $.when(); @@ -148,7 +149,7 @@ Discuss.include({ * * @private * @param {string} type the thread's type to display (e.g. 'mailbox_inbox', - * 'mailbox_starred', 'dm'...). + * 'mailbox_starred', 'dm_chat'...). */ _updateContent: function (type) { var self = this; diff --git a/addons/mail/static/src/js/models/messages/abstract_message.js b/addons/mail/static/src/js/models/messages/abstract_message.js index 420ac2da41b..f5cf55f0ef1 100644 --- a/addons/mail/static/src/js/models/messages/abstract_message.js +++ b/addons/mail/static/src/js/models/messages/abstract_message.js @@ -344,7 +344,7 @@ var AbstractMessage = Class.extend({ * @return {boolean} */ shouldRedirectToAuthor: function () { - return !this._isAuthor(); + return !this._isMyselfAuthor(); }, //-------------------------------------------------------------------------- @@ -370,7 +370,7 @@ var AbstractMessage = Class.extend({ * @private * @return {boolean} */ - _isAuthor: function () { + _isMyselfAuthor: function () { return this.hasAuthor() && (this.getAuthorID() === session.partner_id); }, /** diff --git a/addons/mail/static/src/js/models/messages/message.js b/addons/mail/static/src/js/models/messages/message.js index 0c6480f3264..881a5ae2c97 100644 --- a/addons/mail/static/src/js/models/messages/message.js +++ b/addons/mail/static/src/js/models/messages/message.js @@ -357,14 +357,6 @@ var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin 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) * @@ -381,6 +373,14 @@ var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin isLinkedToDocumentThread: function () { return !!(this._documentModel !== 'mail.channel' && this._documentID); }, + /** + * State whether the current user is the author of this message + * + * @return {boolean} + */ + isMyselfAuthor: function () { + return this._isMyselfAuthor(); + }, /** * States whether the current message needs moderation in general. * diff --git a/addons/mail/static/src/js/models/threads/abstract_thread.js b/addons/mail/static/src/js/models/threads/abstract_thread.js index 67929517ed4..fb6695c6a6c 100644 --- a/addons/mail/static/src/js/models/threads/abstract_thread.js +++ b/addons/mail/static/src/js/models/threads/abstract_thread.js @@ -2,6 +2,7 @@ odoo.define('mail.model.AbstractThread', function (require) { "use strict"; var Class = require('web.Class'); +var Mixins = require('web.mixins'); /** * Abstract thread is the super class of all threads, either backend threads @@ -9,15 +10,20 @@ var Class = require('web.Class'); * * Abstract threads contain abstract messages */ -var AbstractThread = Class.extend({ +var AbstractThread = Class.extend(Mixins.EventDispatcherMixin, { /** * @param {Object} params * @param {Object} params.data * @param {integer|string} params.data.id the ID of this thread * @param {string} params.data.name the name of this thread * @param {string} params.data.status the status of this thread + * @param {Object} params.parent Object with the event-dispatcher mixin + * (@see {web.mixins.EventDispatcherMixin}) */ init: function (params) { + Mixins.EventDispatcherMixin.init.call(this, arguments); + this.setParent(params.parent); + this._folded = false; // threads are unfolded by default this._id = params.data.id; this._name = params.data.name; @@ -29,6 +35,15 @@ var AbstractThread = Class.extend({ // Public //-------------------------------------------------------------------------- + /** + * Add a message to this thread. + * + * @param {mail.model.AbstractMessage} message + */ + addMessage: function (message) { + this._addMessage.apply(this, arguments); + this.trigger('message_added', message); + }, /** * Updates the folded state of the thread * @@ -87,12 +102,6 @@ var AbstractThread = Class.extend({ hasMessages: function () { return !_.isEmpty(this.getMessages()); }, - /** - * Increments the unread counter of this thread by 1 unit. - */ - incrementUnreadCounter: function () { - this._unreadCounter++; - }, /** * States whether this thread is folded or not. * @@ -101,12 +110,77 @@ var AbstractThread = Class.extend({ isFolded: function () { return this._folded; }, + /** + * Mark the thread as read, which resets the unread counter to 0. This is + * only performed if the unread counter is not 0. + * + * @returns {$.Promise} + */ + markAsRead: function () { + if (this._unreadCounter > 0) { + return this._markAsRead(); + } + return $.when(); + }, + /** + * Post a message on this thread + * + * @returns {$.Promise} resolved with the message object to be sent to the + * server + */ + postMessage: function () { + return this._postMessage.apply(this, arguments) + .then(this.trigger.bind(this, 'message_posted')); + }, /** * Resets the unread counter of this thread to 0. */ resetUnreadCounter: function () { this._unreadCounter = 0; }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add a message to this thread. + * + * @abstract + * @private + * @param {mail.model.AbstractMessage} message + */ + _addMessage: function (message) {}, + /** + * Increments the unread counter of this thread by 1 unit. + * + * @private + */ + _incrementUnreadCounter: function () { + this._unreadCounter++; + }, + /** + * Mark the thread as read + * + * @private + * @returns {$.Promise} + */ + _markAsRead: function () { + this.resetUnreadCounter(); + this._warnUpdatedUnreadCounter(); + return $.when(); + }, + /** + * Post a message on this thread + * + * @abstract + * @private + * @returns {$.Promise} resolved with the message object to be sent to the + * server + */ + _postMessage: function () { + return $.when(); + }, }); return AbstractThread; diff --git a/addons/mail/static/src/js/models/threads/channel.js b/addons/mail/static/src/js/models/threads/channel.js index a33319c7f1f..3f932ccb8ec 100644 --- a/addons/mail/static/src/js/models/threads/channel.js +++ b/addons/mail/static/src/js/models/threads/channel.js @@ -1,7 +1,7 @@ odoo.define('mail.model.Channel', function (require) { "use strict"; -var ThreadWithCache = require('mail.model.ThreadWithCache'); +var SearchableThread = require('mail.model.SearchableThread'); var mailUtils = require('mail.utils'); var session = require('web.session'); @@ -15,12 +15,11 @@ var time = require('web.time'); * 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({ +var Channel = SearchableThread.extend({ /** * @override * @param {Object} params * @param {Object} params.data - * @param {string} [params.data.anonymous_name] * @param {string} params.data.channel_type * @param {integer} [params.data.create_uid] the ID of the user that has * created the channel. @@ -29,9 +28,7 @@ var Channel = ThreadWithCache.extend({ * @param {boolean} params.data.is_moderator whether the current user is * moderator of this channel. * @param {string} [params.data.last_message_date] date in server-format - * @param {boolean} [params.data.mass_mailing] * @param {integer} [params.data.message_unread_counter] - * @param {string} [params.data.public] either 'public' or 'private' * @param {string} params.data.state * @param {string} [params.data.uuid] * @param {Object} params.options @@ -48,9 +45,8 @@ var Channel = ThreadWithCache.extend({ // 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 + // exception: receiving channel 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._creatorUID = data.create_uid; this._detached = data.is_minimized; @@ -58,12 +54,12 @@ var Channel = ThreadWithCache.extend({ this._folded = data.state === 'folded'; // if set: hide 'Leave channel' button this._groupBasedSubscription = data.group_based_subscription; - this._isModerator = data.is_moderator; + this._isModerated = data.is_moderation; + this._isMyselfModerator = data.is_moderator; this._lastMessageDate = undefined; - this._massMailing = data.mass_mailing; + this._members = []; // 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; @@ -78,16 +74,9 @@ var Channel = ThreadWithCache.extend({ 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; } @@ -190,6 +179,7 @@ var Channel = ThreadWithCache.extend({ * @returns {$.Promise} resolved with list of channel listeners */ getMentionPartnerSuggestions: function () { + var self = this; if (!this._membersDef) { this._membersDef = this._rpc({ model: 'mail.channel', @@ -199,6 +189,7 @@ var Channel = ThreadWithCache.extend({ shadow: true }) .then(function (members) { + self._members = members; return members; }); } @@ -215,7 +206,7 @@ var Channel = ThreadWithCache.extend({ */ getPreview: function () { var result = this._super.apply(this, arguments); - if (!this.isChat()) { + if (!this.isTwoUserThread()) { result.imageSRC = '/web/image/mail.channel/' + this.getID() + '/image_small'; } var lastMessage = this.getLastMessage(); @@ -223,7 +214,7 @@ var Channel = ThreadWithCache.extend({ author: lastMessage ? lastMessage.getDisplayedAuthor() : '', body: lastMessage ? mailUtils.parseAndTransform(lastMessage.getBody(), mailUtils.inline) : '', date: lastMessage ? lastMessage.getDate() : moment(), - isAuthor: this.hasMessages() && this.getLastMessage().isAuthor(), + isMyselfAuthor: this.hasMessages() && this.getLastMessage().isMyselfAuthor(), }); }, /** @@ -251,15 +242,6 @@ var Channel = ThreadWithCache.extend({ incrementNeedactionCounter: function () { this._needactionCounter++; }, - /** - * Tells whether the current user is administrator of the channel. - * Note that there is no administrator for chat channels - * - * @returns {boolean} - */ - isAdministrator: function () { - return session.uid === this._creatorUID && !this.isChat(); - }, /** * States whether the channel should be auto-selected on creation * @@ -281,18 +263,6 @@ var Channel = ThreadWithCache.extend({ 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 * @@ -308,81 +278,32 @@ var Channel = ThreadWithCache.extend({ * @returns {boolean} */ isModerated: function () { - return this._moderation; + return this._isModerated; + }, + /** + * Tells whether the current user is administrator of the channel. + * Note that there is no administrator for two-user channels + * + * @returns {boolean} + */ + isMyselfAdministrator: function () { + return session.uid === this._creatorUID && !this.isTwoUserThread(); }, /** * 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} 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} 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; - }); - }); + isMyselfModerator: function () { + return this._isMyselfModerator; }, /** * Unsubscribes from channel * + * @abstract * @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], - }); - } - }, + unsubscribe: function () {}, /** * Updates the internal state of the channel, and reflects the changes in * the UI. @@ -453,6 +374,47 @@ var Channel = ThreadWithCache.extend({ ['need_moderation', '=', true], ['channel_ids', 'in', [this._id]]]; }, + /** + * 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 + * @private + * @returns {$.Promise} resolved when message has been marked as read + */ + _markAsRead: function () { + var superDef = this._super.apply(this, arguments); + var seenDef = this._throttleFetchSeen(); + return $.when(superDef, seenDef); + }, + /** + * Prepare and send a message to the server on this channel. + * + * @override + * @private + * @param {Object} data data related to the new message + * @returns {$.Promise} 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; + }); + }); + }, }); return Channel; diff --git a/addons/mail/static/src/js/models/threads/dm.js b/addons/mail/static/src/js/models/threads/dm_chat.js similarity index 66% rename from addons/mail/static/src/js/models/threads/dm.js rename to addons/mail/static/src/js/models/threads/dm_chat.js index fba63830a55..767a6e32edd 100644 --- a/addons/mail/static/src/js/models/threads/dm.js +++ b/addons/mail/static/src/js/models/threads/dm_chat.js @@ -1,19 +1,18 @@ -odoo.define('mail.model.DM', function (require) { +odoo.define('mail.model.DMChat', function (require) { "use strict"; -var Channel = require('mail.model.Channel'); +var TwoUserChannel = require('mail.model.TwoUserChannel'); /** - * * 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({ +var DMChat = TwoUserChannel.extend({ /** * @override * @param {Object} params * @param {Object} params.data - * @param {Object} params.data.direct_partner + * @param {Object[]} params.data.direct_partner * @param {integer} params.data.direct_partner[0].id * @param {string} params.data.direct_partner[0].im_status * @param {string} params.data.direct_partner[0].name @@ -23,10 +22,10 @@ var DM = Channel.extend({ var data = params.data; - this._type = 'dm'; - this._name = data.direct_partner[0].name; this._directPartnerID = data.direct_partner[0].id; + this._name = data.direct_partner[0].name; this._status = data.direct_partner[0].im_status; + this._type = 'dm_chat'; }, //-------------------------------------------------------------------------- @@ -35,8 +34,8 @@ var DM = Channel.extend({ /** * 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. + * user at the other end of the DM conversation. All DM chats do have a + * direct partner iD. * * @returns {integer} */ @@ -51,36 +50,14 @@ var DM = Channel.extend({ result.imageSRC = '/web/image/res.partner/' + this.getDirectPartnerID() + '/image_small'; return result; }, - /** - * 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], - }); - }, }); -return DM; +return DMChat; }); diff --git a/addons/mail/static/src/js/models/threads/document_thread.js b/addons/mail/static/src/js/models/threads/document_thread.js index 831c46939a8..283bcf3ca66 100644 --- a/addons/mail/static/src/js/models/threads/document_thread.js +++ b/addons/mail/static/src/js/models/threads/document_thread.js @@ -57,28 +57,6 @@ var DocumentThread = Thread.extend({ // Public //-------------------------------------------------------------------------- - /** - * Add this message to this document thread. - * - * This is ignored if the message is already linked to this document thread. - * - * @override - * @param {mail.model.Message} message - */ - addMessage: function (message) { - if (_.contains(this._messages, message)) { - return; - } - // 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. @@ -178,59 +156,6 @@ var DocumentThread = Thread.extend({ 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} 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 * @@ -259,6 +184,30 @@ var DocumentThread = Thread.extend({ // Private //-------------------------------------------------------------------------- + /** + * Add this message to this document thread. + * + * This is ignored if the message is already linked to this document thread. + * + * @override + * @private + * @param {mail.model.Message} message + */ + _addMessage: function (message) { + this._super.apply(this, arguments); + if (_.contains(this._messages, message)) { + return; + } + // 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 most up to date messageIDs * @@ -324,6 +273,63 @@ var DocumentThread = Thread.extend({ }); }, + /** + * Overrides this method so that all the messages of this document thread + * are marked as read on the server. + * + * @override + * @private + * @returns {$.Promise} resolved when messages have been marked as read on + * the server. + */ + _markAsRead: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.call('mail_service', 'markMessagesAsRead', self._messageIDs); + }); + }, + /** + * Post message for document thread + * + * @override + * @private + * @param {Object} data data related to the new message + * @return {$.Promise} 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]; + }); + }); + }); + }, }); return DocumentThread; diff --git a/addons/mail/static/src/js/models/threads/livechat.js b/addons/mail/static/src/js/models/threads/livechat.js new file mode 100644 index 00000000000..d2aa04f926a --- /dev/null +++ b/addons/mail/static/src/js/models/threads/livechat.js @@ -0,0 +1,57 @@ +odoo.define('mail.model.Livechat', function (require) { +"use strict"; + +var TwoUserChannel = require('mail.model.TwoUserChannel'); + +/** + * backend-side of the livechat. + * + * Any piece of code in JS that make use of Livechats must ideally interact with + * such objects, instead of direct data from the server. + */ +var Livechat = TwoUserChannel.extend({ + _WEBSITE_USER_ID: '_websiteUser', + _WEBSITE_USER_NAME: 'Website user', + + /** + * @override + * @param {Object} params + * @param {Object} params.data + * @param {string} params.data.anonymous_name name of the website user + */ + init: function (params) { + this._super.apply(this, arguments); + + this._name = params.data.anonymous_name; + + this._WEBSITE_USER_NAME = this._name; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * For the livechat, + * + * @override + * @returns {$.Promise} resolved with list of livechat members + */ + getMentionPartnerSuggestions: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + var websiteUser = _.findWhere(self._members, { id: self._WEBSITE_USER_ID }); + if (!websiteUser) { + self._members.push({ + id: self._WEBSITE_USER_ID, + name: self._WEBSITE_USER_NAME, + }); + } + return self._members; + }); + }, +}); + +return Livechat; + +}); diff --git a/addons/mail/static/src/js/models/threads/mailbox.js b/addons/mail/static/src/js/models/threads/mailbox.js index 4ba7c4e8d1f..172b5af9e3b 100644 --- a/addons/mail/static/src/js/models/threads/mailbox.js +++ b/addons/mail/static/src/js/models/threads/mailbox.js @@ -1,7 +1,7 @@ odoo.define('mail.model.Mailbox', function (require) { "use strict"; -var ThreadWithCache = require('mail.model.ThreadWithCache'); +var SearchableThread = require('mail.model.SearchableThread'); var core = require('web.core'); @@ -14,7 +14,7 @@ var _t = core._t; * list of messages, but the inbox does not represent a conversation: Inbox is * modeled as a mailbox. */ -var Mailbox = ThreadWithCache.extend({ +var Mailbox = SearchableThread.extend({ /** * @override diff --git a/addons/mail/static/src/js/models/threads/multi_user_channel.js b/addons/mail/static/src/js/models/threads/multi_user_channel.js new file mode 100644 index 00000000000..c1681744127 --- /dev/null +++ b/addons/mail/static/src/js/models/threads/multi_user_channel.js @@ -0,0 +1,56 @@ +odoo.define('mail.model.MultiUserChannel', function (require) { +"use strict"; + +var Channel = require('mail.model.Channel'); + +var MultiUserChannel = Channel.extend({ + /** + * @override + * @param {Object} params + * @param {Object} params.data + * @param {boolean} [params.data.mass_mailing=false] + * @param {string} [params.data.public='public'] either 'public' or + * 'private' + */ + init: function (params) { + this._super.apply(this, arguments); + + var data = params.data; + + this._isMassMailing = data.mass_mailing || false; + this._type = data.public !== 'private' ? 'public' : 'private'; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * 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._isMassMailing; + }, + /** + * Unsubscribes from channel + * + * @override + * @returns {$.Promise} resolve when unsubscribed + */ + unsubscribe: function () { + return this._rpc({ + model: 'mail.channel', + method: 'action_unfollow', + args: [[this.getID()]], + }); + }, + +}); + +return MultiUserChannel; + +}); diff --git a/addons/mail/static/src/js/models/threads/thread_with_cache.js b/addons/mail/static/src/js/models/threads/searchable_thread.js similarity index 96% rename from addons/mail/static/src/js/models/threads/thread_with_cache.js rename to addons/mail/static/src/js/models/threads/searchable_thread.js index c82432c64d7..fef42c3ffc4 100644 --- a/addons/mail/static/src/js/models/threads/thread_with_cache.js +++ b/addons/mail/static/src/js/models/threads/searchable_thread.js @@ -1,4 +1,4 @@ -odoo.define('mail.model.ThreadWithCache', function (require) { +odoo.define('mail.model.SearchableThread', function (require) { "use strict"; var Thread = require('mail.model.Thread'); @@ -13,7 +13,7 @@ var session = require('web.session'); * 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({ +var SearchableThread = Thread.extend({ /** * @override @@ -38,23 +38,6 @@ var ThreadWithCache = Thread.extend({ // 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); - } - }, /** * @override * @param {Array} [domain] @@ -128,6 +111,24 @@ var ThreadWithCache = Thread.extend({ // Private //-------------------------------------------------------------------------- + /** + * Add the message to this thread + * + * @override + * @private + * @param {mail.model.Message} message + * @param {Array} domain + */ + _addMessage: function (message, domain) { + this._super.apply(this, arguments); + 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); + } + }, /** * Gets messages from thread * @@ -202,6 +203,6 @@ var ThreadWithCache = Thread.extend({ _getThreadDomain: function () {}, }); -return ThreadWithCache; +return SearchableThread; }); diff --git a/addons/mail/static/src/js/models/threads/thread.js b/addons/mail/static/src/js/models/threads/thread.js index 5fcd0285a82..c9dda28dd93 100644 --- a/addons/mail/static/src/js/models/threads/thread.js +++ b/addons/mail/static/src/js/models/threads/thread.js @@ -5,7 +5,6 @@ var emojis = require('mail.emojis'); var AbstractThread = require('mail.model.AbstractThread'); var mailUtils = require('mail.utils'); -var Mixins = require('web.mixins'); var ServicesMixin = require('web.ServicesMixin'); /** @@ -15,7 +14,7 @@ var ServicesMixin = require('web.ServicesMixin'); * * In particular, channels and mailboxes are two different kinds of threads. */ -var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, { +var Thread = AbstractThread.extend(ServicesMixin, { /** * @override @@ -27,15 +26,9 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, { * @param {string} [params.data.type] */ init: function (params) { - Mixins.EventDispatcherMixin.init.call(this, arguments); - this.setParent(params.parent); this._super.apply(this, arguments); // threads are not detached by default this._detached = 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; // on 1st request to getPreview, fetch data if incomplete. Otherwise it // means that there is no message in this channel. this._previewed = false; @@ -48,13 +41,6 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, { // 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. @@ -140,7 +126,7 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, { return { id: this.getID(), imageSRC: '/mail/static/src/img/smiley/avatar.jpg', - isChat: this.isChat(), + isTwoUserThread: this.isTwoUserThread(), status: this.getStatus(), title: this.getName(), unreadCounter: this.getUnreadCounter(), @@ -188,16 +174,6 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, { * @abstract */ incrementNeedactionCounter: function () {}, - /** - * Increment the unread counter of this thread by 1 unit, and warn that the - * counter has been changed. - * - * @override - */ - incrementUnreadCounter: function () { - this._super.apply(this, arguments); - this._warnUpdatedUnreadCounter(); - }, /** * States whether the thread should be auto-selected on creation * @@ -222,17 +198,6 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, { 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. @@ -256,10 +221,12 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, { * This is a server-side setting, that determine the type of composer that * is used (basic or extended composer). * + * By default, thread have not mass-mailings feature set. + * * @return {boolean} */ isMassMailing: function () { - return this._massMailing; + return false; }, /** @@ -277,7 +244,18 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, { * * @returns {boolean} */ - isModerator: function () { + isMyselfModerator: function () { + return false; + }, + /** + * States whether this is a two-user thread or not. In particular, public + * and private channels are not two-user threads, but DMs and Livechats are + * two-user threads. Two-user threads are used for communication between + * two users. By default, any thread is not two-user thread. + * + * @returns {boolean} + */ + isTwoUserThread: function () { return false; }, /** @@ -294,59 +272,6 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, { 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.resetUnreadCounter(); - } - 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} 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, - canned_response_ids: data.canned_response_ids, - }; - if ('subject' in data) { - messageData.subject = data.subject; - } - return $.when(messageData); - }, - /** - * Overrides the method so that it also warns that the counter has been - * changed on this thread. - * - * @override - * @private - */ - resetUnreadCounter: function () { - this._super.apply(this, arguments); - this._warnUpdatedUnreadCounter(); - }, //-------------------------------------------------------------------------- // Private @@ -379,13 +304,58 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, { return htmlString; }, /** - * Warn on the chat bus that the unread counter has been updated + * Increment the unread counter of this thread by 1 unit, and warn that the + * counter has been changed. * + * @override + * @private + */ + _incrementUnreadCounter: function () { + this._super.apply(this, arguments); + this._warnUpdatedUnreadCounter(); + }, + /** + * 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 + * @private + * @param {Object} data + * @returns {$.Promise} 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, + canned_response_ids: data.canned_response_ids, + }; + if ('subject' in data) { + messageData.subject = data.subject; + } + return this._super.apply(this, arguments).then(function () { + return $.when(messageData); + }); + }, + /** + * Warn on the mail bus that the unread counter has been updated + * + * @override * @private */ _warnUpdatedUnreadCounter: function () { - this.call('mail_service', 'getMailBus') - .trigger('update_thread_unread_counter', this); + this.call('mail_service', 'getMailBus').trigger('update_thread_unread_counter', this); }, /** * Warn other mail components that the window state of this thread has @@ -393,6 +363,7 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, { * * @private * @param {Object} [options={}] + * @param {boolean} [options.passively=false] */ _warnUpdatedWindowState: function (options) { options = options || {}; diff --git a/addons/mail/static/src/js/models/threads/two_user_channel.js b/addons/mail/static/src/js/models/threads/two_user_channel.js new file mode 100644 index 00000000000..204fd95eb82 --- /dev/null +++ b/addons/mail/static/src/js/models/threads/two_user_channel.js @@ -0,0 +1,32 @@ +odoo.define('mail.model.TwoUserChannel', function (require) { +"use strict"; + +var Channel = require('mail.model.Channel'); + +var TwoUserChannel = Channel.extend({ + /** + * @override + * @returns {boolean} + */ + isTwoUserThread: function () { + return true; + }, + /** + * Unpin from two-user thread + * + * @override + * @returns {$.Promise} resolve when unsubscribed + */ + unsubscribe: function () { + return this._rpc({ + model: 'mail.channel', + method: 'channel_pin', + args: [this.getUUID(), false], + }); + }, + +}); + +return TwoUserChannel; + +}); diff --git a/addons/mail/static/src/js/services/mail_manager.js b/addons/mail/static/src/js/services/mail_manager.js index 23d19caf31f..35659d04c7b 100644 --- a/addons/mail/static/src/js/services/mail_manager.js +++ b/addons/mail/static/src/js/services/mail_manager.js @@ -26,11 +26,12 @@ odoo.define('mail.Manager', function (require) { */ var AbstractService = require('web.AbstractService'); -var Channel = require('mail.model.Channel'); -var DM = require('mail.model.DM'); +var DMChat = require('mail.model.DMChat'); +var Livechat = require('mail.model.Livechat'); var Mailbox = require('mail.model.Mailbox'); var MailFailure = require('mail.model.MailFailure'); var Message = require('mail.model.Message'); +var MultiUserChannel = require('mail.model.MultiUserChannel'); var mailUtils = require('mail.utils'); var Bus = require('web.Bus'); @@ -39,7 +40,6 @@ var core = require('web.core'); var session = require('web.session'); var _t = core._t; -var _lt = core._lt; var PREVIEW_MSG_MAX_SIZE = 350; // optimal for native english speakers @@ -52,30 +52,10 @@ var MailManager = AbstractService.extend({ * @override */ start: function () { - this._cannedResponses = []; - this._mailBus = new Bus(this); - this._commands = []; - this._discussMenuID = undefined; - this._discussOpen = false; - this._isModerator = false; - this._mailFailures = []; - // list of employees for chatter mentions - this._mentionPartnerSuggestions = []; - this._messages = []; - this._moderatedChannelIDs = []; - // # of message received when odoo is out of focus - this._outOfFocusUnreadMessageCounter = 0; - // partner_ids we have a pinned DM with - this._pinnedDmPartners = []; - // all threads, including channels, DM, mailboxes, document threads, ... - this._threads = []; - - // listen on buses - this._mailBus - .on('discuss_open', this, this._onDiscussOpen) - .on('window_focus', this, this._onWindowFocus); - - this._initializeFromServer(); + this._super.apply(this, arguments); + this._initializeInternalState(); + this._listenOnBuses(); + this._fetchMailStateFromServer(); }, //-------------------------------------------------------------------------- @@ -100,17 +80,7 @@ var MailManager = AbstractService.extend({ options = options || {}; var message = this.getMessage(data.id); if (!message) { - message = this._makeMessage(data); - // Keep the array ordered by ID when inserting the new message - var index = _.sortedIndex(this._messages, message, function (msg) { - return msg.getID(); - }); - this._messages.splice(index, 0, message); - this._addNewMessagePostprocessThread(message, options); - this._addMessageToThreads(message, []); - if (!options.silent) { - this._mailBus.trigger('new_message', message); - } + message = this._addNewMessage(data, options); } else { if (data.moderation_status === 'accepted') { message.setModerationStatus('accepted', { @@ -124,16 +94,17 @@ var MailManager = AbstractService.extend({ return message; }, /** - * Creates a channel, can be either a true channel or a DM based on `type` + * Creates a channel, can be either a true channel or a DM chat based on + * `type` * * @param {integer|string} name id of partner (in case of dm) or name - * @param {string} type ['dm', 'public', 'private'] + * @param {string} type ['dm_chat', 'public', 'private'] * @returns {$.Promise} resolved with ID of the newly created * channel */ createChannel: function (name, type) { - if (type === 'dm') { - return this._createDM(name); + if (type === 'dm_chat') { + return this._createDMChat(name); } else { return this._createChannel(name, type); } @@ -202,9 +173,9 @@ var MailManager = AbstractService.extend({ * @param {integer} partnerID * @returns {Object|undefined} channel */ - getDmFromPartnerID: function (partnerID) { + getDMChatFromPartnerID: function (partnerID) { return _.find(this._threads, function (thread) { - return thread.getType() === 'dm' && + return thread.getType() === 'dm_chat' && thread.getDirectPartnerID() === partnerID; }); }, @@ -314,8 +285,8 @@ var MailManager = AbstractService.extend({ * * @returns {boolean} */ - isModerator: function () { - return this._isModerator; + isMyselfModerator: function () { + return this._isMyselfModerator; }, /** * States whether the mail manager is ready or not @@ -381,7 +352,7 @@ var MailManager = AbstractService.extend({ * Special redirection handling for given model and id * * If the model is res.partner, and there is a user associated with this - * partner which isn't the current user, open the DM with this user. + * partner which isn't the current user, open the DM chat with this user. * Otherwhise, open the record's form view (if not current user's). * * @param {string} resModel model to open @@ -530,6 +501,28 @@ var MailManager = AbstractService.extend({ } }); }, + /** + * Add a new message + * + * @private + * @param {Object} data + * @param {Object} options + * @returns {mail.model.Message} + */ + _addNewMessage: function (data, options) { + var message = this._makeMessage(data); + // Keep the array ordered by ID when inserting the new message + var index = _.sortedIndex(this._messages, message, function (msg) { + return msg.getID(); + }); + this._messages.splice(index, 0, message); + this._addNewMessagePostprocessThread(message, options); + this._addMessageToThreads(message, []); + if (!options.silent) { + this._mailBus.trigger('new_message', message); + } + return message; + }, /** * For newly added message, postprocess threads linked to this message * @@ -547,13 +540,14 @@ var MailManager = AbstractService.extend({ if (thread) { if ( thread.getType() !== 'mailbox' && - !message.isAuthor() && + !message.isMyselfAuthor() && !message.isSystemNotification() ) { + // HERE (remove below line for final version) if (options.incrementUnread) { thread.incrementUnreadCounter(); } - if (thread.isChat() && options.showNotification) { + if (thread.isTwoUserThread() && options.showNotification) { if ( !self._isDiscussOpen() && !config.device.isMobile && @@ -563,10 +557,10 @@ var MailManager = AbstractService.extend({ // while keeping it unread thread.detach({ passively: true }); } - var query = { isBottomVisible: false }; + var query = { isVisible: false }; self._mailBus.trigger('is_thread_bottom_visible', thread, query); - if (!query.isBottomVisible) { - self._notifyIncomingMessage(message, query); + if (!self.call('bus_service', 'isOdooFocused') || !query.isVisible) { + self._notifyIncomingMessage(message); } } } @@ -598,7 +592,7 @@ var MailManager = AbstractService.extend({ * @param {string} name * @returns {$.Promise} ID of the created channel */ - _createDM: function (name) { + _createDMChat: function (name) { var context = _.extend({ isMobile: config.device.isMobile }, session.user_context); return this._rpc({ model: 'mail.channel', @@ -620,6 +614,25 @@ var MailManager = AbstractService.extend({ args: [channelIDs], }, { shadow: true }); }, + /** + * @private + */ + _fetchMailStateFromServer: function () { + var self = this; + this._isReady = session.is_bound.then(function () { + var context = _.extend( + { isMobile: config.device.isMobile }, + session.user_context + ); + return self._rpc({ + route: '/mail/init_messaging', + params: { context: context }, + }); + }).then(function (result) { + self._updateInternalStateFromServer(result); + self.call('bus_service', 'startPolling'); + }); + }, /** * Get previews of the channels * @@ -744,9 +757,9 @@ var MailManager = AbstractService.extend({ return false; } if (filter === 'chat') { - return thread.isChat(); + return thread.isTwoUserThread(); } else if (filter === 'channels') { - return !thread.isChat(); + return !thread.isTwoUserThread(); } return true; }); @@ -809,6 +822,8 @@ var MailManager = AbstractService.extend({ * * @private * @param {string|undefined} [filter] + * @returns {$.Promise} resolved with list of object that are + * compatible with preview template of mail systray menu */ _getSystrayStaticPreviews: function (filter) { var elems = []; @@ -827,132 +842,29 @@ var MailManager = AbstractService.extend({ return $.when(elems); }, /** - * Initialize the canned responses from the server data + * Initialize the internal state of the mail service. Ensure that all + * attributes are set before doing any operation on them. * * @private - * @param {Object} data - * @param {Object[]} [data.shortcodes] - * @param {integer} data.shortcodes[i].id - * @param {string} data.shortcodes[i].source - * @param {string} data.shortcodes[i].substitution */ - _initializeCannedResponses: function (data) { - var self = this; - _.each(data.shortcodes, function (s) { - var cannedResponse = _.pick(s, ['id', 'source', 'substitution']); - self._cannedResponses.push(cannedResponse); - }); - }, - /** - * Initialize the channels from the server, including public, private, DM, - * livechat, etc. - * - * @private - * @param {Object} data - * @param {Object} [data.channel_slots] contains the data of channels to - * initialize, which are grouped by channel type by key of the object - * (e.g. list of public channel data are stored in 'channel_channel') - * @param {Object[]} [data.channel_slots[i] list of data of channel of type - * `i` - */ - _initializeChannels: function (data) { - var self = this; - _.each(data.channel_slots, function (channels) { - _.each(channels, self._addChannel.bind(self)); - }); - }, - /** - * Initialize commands from the server - * - * @private - * @param {Object} data - * @param {Object[]} data.commands list of command data from the server - */ - _initializeCommands: function (data) { - this._commands = _.map(data.commands, function (command) { - return _.extend({ id: command.name }, command); - }); - }, - /** - * @private - * @returns {$.Promise} - */ - _initializeFromServer: function () { - var self = this; - this._isReady = session.is_bound.then(function () { - var context = _.extend( - { isMobile: config.device.isMobile }, - session.user_context - ); - return self._rpc({ - route: '/mail/init_messaging', - params: { context: context }, - }); - }).then(function (result) { - self._updateFromServer(result); - self.call('bus_service', 'startPolling'); - }); - }, - /** - * Initialize the mailboxes, namely 'Inbox', 'Starred', - * and 'Moderation Queue' if the user is a moderator of a channel - * - * @private - * @param {Object} data - * @param {boolean} [data.is_moderator=false] states whether the user is - * moderator of a channel - * @param {integer} [data.moderation_counter=0] states the mailbox counter - * to set to 'Moderation Queue' - * @param {integer} [data.needaction_inbox_counter=0] states the mailbox - * counter to set to 'Inbox' - * @param {integer} [data.starred_counter=0] states the mailbox counter to - * set to 'Starred' - */ - _initializeMailboxes: function (data) { - this._addMailbox({ - id: 'inbox', - name: _t("Inbox"), - mailboxCounter: data.needaction_inbox_counter || 0, - }); - this._addMailbox({ - id: 'starred', - name: _t("Starred"), - mailboxCounter: data.starred_counter || 0, - }); - - if (data.is_moderator) { - this._addMailbox({ - id: 'moderation', - name: _t("Moderate Messages"), - mailboxCounter: data.moderation_counter || 0, - }); - } - }, - /** - * Initialize mail failures from the server data - * - * @private - * @param {Object} data - * @param {Object[]} data.mail_failures data to initialize mail failures - * locally - */ - _initializeMailFailures: function (data) { - var self = this; - this._mailFailures = _.map(data.mail_failures, function (mailFailureData) { - return new MailFailure(self, mailFailureData); - }); - }, - /** - * Initialize moderation settings from the server data - * - * @private - * @param {Object} data - * @param {boolean} [data.is_moderator=false] - * @param {integer[]} [data.moderation_channel_ids] - */ - _initializeModerationSettings: function (data) { - this._moderatedChannelIDs = data.moderation_channel_ids; - this._isModerator = data.is_moderator; + _initializeInternalState: function () { + this._cannedResponses = []; + this._mailBus = new Bus(this); + this._commands = []; + this._discussMenuID = undefined; + this._discussOpen = false; + this._isMyselfModerator = false; + this._mailFailures = []; + // list of employees for chatter mentions + this._mentionPartnerSuggestions = []; + this._messages = []; + this._moderatedChannelIDs = []; + // # of message received when odoo is out of focus + this._outOfFocusUnreadMessageCounter = 0; + // partner_ids we have a pinned DM chat with + this._pinnedDmPartners = []; + // all threads, including channels, DM, mailboxes, document threads, ... + this._threads = []; }, /** * State whether discuss app is open or not @@ -982,26 +894,46 @@ var MailManager = AbstractService.extend({ return self._addChannel(result, options); }); }, + /** + * Listen on several buses, before doing any action that trigger something + * on those buses. + * + * @private + */ + _listenOnBuses: function () { + this._mailBus.on('discuss_open', this, this._onDiscussOpen); + this.call('bus_service', 'on', 'window_focus', this, this._onWindowFocus); + }, /** * Creates a new instance of Channel with the given data and options. * * @private * @param {Object} data + * @param {Array} [data.channel_type] if set and is 'livechat', the channel + * is a Livechat. * @param {Array} [data.direct_partner] if set and is an non-empty array, - * the channel is a DM + * the channel is a DM chat * @param {Object} [options] * @returns {mail.model.Channel} */ _makeChannel: function (data, options) { if (_.size(data.direct_partner) > 0) { - return new DM({ + return new DMChat({ parent: this, data: data, options: options, commands: this._commands }); } - return new Channel({ + if (data.channel_type === 'livechat') { + return new Livechat({ + parent: this, + data: data, + options: options, + commands: this._commands, + }); + } + return new MultiUserChannel({ parent: this, data: data, options: options, @@ -1020,16 +952,13 @@ var MailManager = AbstractService.extend({ }, /** * shows a popup to notify a new received message. - * This will also rename the odoo tab browser if - * the user has no focus on it. + * This will also rename the browser tab if this is not the active tab. * * @private * @param {mail.model.Message} message message received - * @param {Object} options - * @param {boolean} options.isDisplayed */ - _notifyIncomingMessage: function (message, options) { - if (this.call('bus_service', 'isOdooFocused') && options.isDisplayed) { + _notifyIncomingMessage: function (message) { + if (this.call('bus_service', 'isOdooFocused')) { // no need to notify return; } @@ -1209,7 +1138,7 @@ var MailManager = AbstractService.extend({ * Sort previews * * 1. unread, - * 2. chat, + * 2. two-user thread, * 3. date, * * @private @@ -1219,13 +1148,13 @@ var MailManager = AbstractService.extend({ _sortPreviews: function (previews) { var res = previews.sort(function (p1, p2) { var unreadDiff = Math.min(1, p2.unreadCounter) - Math.min(1, p1.unreadCounter); - var isChatDiff = p2.isChat - p1.isChat; + var isTwoUserThreadDiff = p2.isTwoUserThread - p1.isTwoUserThread; var dateDiff = (!!p2.date - !!p1.date) || ( p2.date && p2.date.diff(p1.date) ); - return unreadDiff || isChatDiff || dateDiff; + return unreadDiff || isTwoUserThreadDiff || dateDiff; }); return res; }, @@ -1244,6 +1173,53 @@ var MailManager = AbstractService.extend({ return _.isString(name) ? name.toLowerCase() : ''; }); }, + /** + * Update the canned responses with mail data fetched from the server + * + * @private + * @param {Object} data + * @param {Object[]} [data.shortcodes] + * @param {integer} data.shortcodes[i].id + * @param {string} data.shortcodes[i].source + * @param {string} data.shortcodes[i].substitution + */ + _updateCannedResponsesFromServer: function (data) { + var self = this; + _.each(data.shortcodes, function (s) { + var cannedResponse = _.pick(s, ['id', 'source', 'substitution']); + self._cannedResponses.push(cannedResponse); + }); + }, + /** + * Update the channels with the mail data fetched from server, including + * public, private, DM, livechat, etc. + * + * @private + * @param {Object} data + * @param {Object} [data.channel_slots] contains the data of channels to + * update, which are grouped by channel type by key of the object + * (e.g. list of public channel data are stored in 'channel_channel') + * @param {Object[]} [data.channel_slots[i] list of data of channel of type + * `i` + */ + _updateChannelsFromServer: function (data) { + var self = this; + _.each(data.channel_slots, function (channels) { + _.each(channels, self._addChannel.bind(self)); + }); + }, + /** + * Update commands from mail data fetched from the server + * + * @private + * @param {Object} data + * @param {Object[]} data.commands list of command data from the server + */ + _updateCommandsFromServer: function (data) { + this._commands = _.map(data.commands, function (command) { + return _.extend({ id: command.name }, command); + }); + }, /** * Update internal state from server data (mail/init_messaging rpc result) * @@ -1253,18 +1229,79 @@ var MailManager = AbstractService.extend({ * with all the employees * @param {integer} result.menu_id the menu ID of discuss app */ - _updateFromServer: function (result) { + _updateInternalStateFromServer: function (result) { // commands are needed for channel instantiation - this._initializeCommands(result); - this._initializeChannels(result); - this._initializeModerationSettings(result); - this._initializeMailboxes(result); - this._initializeMailFailures(result); - this._initializeCannedResponses(result); + this._updateCommandsFromServer(result); + this._updateChannelsFromServer(result); + this._updateModerationSettingsFromServer(result); + this._updateMailboxesFromServer(result); + this._updateMailFailuresFromServer(result); + this._updateCannedResponsesFromServer(result); this._mentionPartnerSuggestions = result.mention_partner_suggestions; this._discussMenuID = result.menu_id; }, + /** + * Update the mailboxes with mail data fetched from server, namely 'Inbox', + * 'Starred', and 'Moderation Queue' if the user is a moderator of a channel + * + * @private + * @param {Object} data + * @param {boolean} [data.is_moderator=false] states whether the user is + * moderator of a channel + * @param {integer} [data.moderation_counter=0] states the mailbox counter + * to set to 'Moderation Queue' + * @param {integer} [data.needaction_inbox_counter=0] states the mailbox + * counter to set to 'Inbox' + * @param {integer} [data.starred_counter=0] states the mailbox counter to + * set to 'Starred' + */ + _updateMailboxesFromServer: function (data) { + this._addMailbox({ + id: 'inbox', + name: _t("Inbox"), + mailboxCounter: data.needaction_inbox_counter || 0, + }); + this._addMailbox({ + id: 'starred', + name: _t("Starred"), + mailboxCounter: data.starred_counter || 0, + }); + + if (data.is_moderator) { + this._addMailbox({ + id: 'moderation', + name: _t("Moderate Messages"), + mailboxCounter: data.moderation_counter || 0, + }); + } + }, + /** + * Update mail failures with mail data fetched from the server + * + * @private + * @param {Object} data + * @param {Object[]} data.mail_failures data to update mail failures + * locally + */ + _updateMailFailuresFromServer: function (data) { + var self = this; + this._mailFailures = _.map(data.mail_failures, function (mailFailureData) { + return new MailFailure(self, mailFailureData); + }); + }, + /** + * Update moderation settings with mail data fetched from server + * + * @private + * @param {Object} data + * @param {boolean} [data.is_moderator=false] + * @param {integer[]} [data.moderation_channel_ids] + */ + _updateModerationSettingsFromServer: function (data) { + this._isMyselfModerator = data.is_moderator; + this._moderatedChannelIDs = data.moderation_channel_ids; + }, //-------------------------------------------------------------------------- // Handlers diff --git a/addons/mail/static/src/js/services/mail_notification_manager.js b/addons/mail/static/src/js/services/mail_notification_manager.js index 475cbd44f90..0e1fc4782d3 100644 --- a/addons/mail/static/src/js/services/mail_notification_manager.js +++ b/addons/mail/static/src/js/services/mail_notification_manager.js @@ -11,16 +11,12 @@ var MailManager = require('mail.Manager'); var MailFailure = require('mail.model.MailFailure'); var core = require('web.core'); +var session = require('web.session'); var _t = core._t; MailManager.include({ - start: function () { - this._super.apply(this, arguments); - this.call('bus_service', 'onNotification', this, this._onNotification); - }, - //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -51,8 +47,9 @@ MailManager.include({ * * @private * @param {Object} messageData - * @param {Array} messageData.channel_ids list of integers and strings, - * where strings for static channels, e.g. 'mailbox_inbox'. + * @param {integer[]} messageData.channel_ids channel IDs of this message + * (note that 'pending moderation' messages in moderated channels do not + * have the moderated channels in this array). */ _handleChannelNotification: function (messageData) { var self = this; @@ -415,29 +412,36 @@ MailManager.include({ * @param {Array} data.messages message to display on notification */ _handlePartnerUserConnectionNotification: function (data) { - var self=this; - var partner_id = data.partner_id; - this.call('bus_service', 'sendNotification', data.title, data.message, function(){ - self.call('mail_service', 'openDmWindow', partner_id); + var self = this; + var partnerID = data.partner_id; + this.call('bus_service', 'sendNotification', data.title, data.message, function ( ){ + self.call('mail_service', 'openDmWindow', partnerID); }); }, /** - * * On receiving an update on user status (e.g. becoming 'online', 'offline', * 'idle', etc.). * * @private * @param {Object} data partner infos - * @param {integer} data.id - * @param {string} data.im_status + * @param {integer} data.id partner ID + * @param {string} data.im_status partner new 'im status' (e.g. 'online') */ _handlePresenceNotification: function (data) { - var dm = this.getDmFromPartnerID(data.id); - if (dm) { - dm.setStatus(data.im_status); - this._mailBus.trigger('update_dm_presence', dm); + var dmChat = this.getDMChatFromPartnerID(data.id); + if (dmChat) { + dmChat.setStatus(data.im_status); + this._mailBus.trigger('update_dm_presence', dmChat); } }, + /** + * @override + * @private + */ + _listenOnBuses: function () { + this._super.apply(this, arguments); + this.call('bus_service', 'onNotification', this, this._onNotification); + }, /** * Update the message notification status of message based on update_message * @@ -481,17 +485,12 @@ MailManager.include({ _.each(notifs, function (notif) { var model = notif[0][1]; if (model === 'ir.needaction') { - // new message in the inbox self._handleNeedactionNotification(notif[1]); } else if (model === 'mail.channel') { - // new message in a channel self._handleChannelNotification(notif[1]); } else if (model === 'res.partner') { - // channel joined/left, message marked as read/(un)starred, - // chat open/closed, moderation specific, etc. self._handlePartnerNotification(notif[1]); } else if (model === 'bus.presence') { - // update presence of users self._handlePresenceNotification(notif[1]); } }); diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js index 733ca366a95..6b15a5a9a38 100644 --- a/addons/mail/static/src/js/services/mail_window_manager.js +++ b/addons/mail/static/src/js/services/mail_window_manager.js @@ -20,35 +20,13 @@ var THREAD_WINDOW_WIDTH = 325 + 5; // 5 pixels between windows * windows. */ MailManager.include({ + custom_events: _.extend({}, MailManager.prototype.custom_events, { + close_blank_thread_window: '_onCloseBlankThreadWindow', + }), // tell where to append thread window THREAD_WINDOW_APPENDTO: 'body', - start: function () { - this._super.apply(this, arguments); - - this._availableSlotsForThreadWindows = 0; - this._hiddenThreadWindows = []; - // used to keep dropdown open when closing thread windows - this._keepHiddenThreadWindowsDropdownOpen = false; - this._spaceLeftForThreadWindows = 0; - this._threadWindows = []; - // jQuery element for the dropdown of hidden thread windows - // see _renderHiddenThreadWindowsDropdown - this._$hiddenThreadWindowsDropdown = null; - - this._mailBus - .on('update_message', this, this._onUpdateMessage) - .on('new_message', this, this._onNewMessage) - .on('new_channel', this, this._onNewChannel) - .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible) - .on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel) - .on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter) - .on('update_dm_presence', this, this._onUpdateDmPresence); - - core.bus.on('resize', this, _.debounce(this._repositionThreadWindows.bind(this), 100)); - }, - //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- @@ -73,18 +51,20 @@ MailManager.include({ } }, /** - * Open a DM in a thread window. This is useful when selecting a DM in the - * blank thread window, so that it replaces it with the DM window. + * Open a DM chat in a thread window. This is useful when selecting a DM + * chatin the blank thread window, so that it replaces it with the DM chat + * window. * * @param {integer} partnerID */ - openDmWindow: function (partnerID) { - var dm = this.getDmFromPartnerID(partnerID); - if (!dm) { - this._openAndDetachDm(partnerID); + openDMChatWindowFromBlankThreadWindow: function (partnerID) { + var dmChat = this.getDMChatFromPartnerID(partnerID); + if (!dmChat) { + this._openAndDetachDMChat(partnerID); } else { - this.openThreadWindow(dm.getID()); + this.openThreadWindow(dmChat.getID()); } + this._closeBlankThreadWindow(); }, /** * Open the thread window if discuss is not opened @@ -129,29 +109,11 @@ MailManager.include({ return thread.fetchMessages(); }).then(function () { threadWindow.render(); - threadWindow.threadWidget.scrollToBottom(); - // setTimeout to prevent to execute handler on first - // scrollTo, which is asynchronous - setTimeout(function () { - threadWindow.threadWidget.$el.on('scroll', null, _.debounce(function () { - if ( - !threadWindow.isPassive() && - threadWindow.threadWidget.isAtBottom() - ) { - thread.markAsRead(); - } - }, 100)); - }, 0); - if (options.passively) { - // mark first unread messages as seen when focusing the - // window, then on scroll to bottom as usual - threadWindow.$('.o_mail_thread, .o_thread_composer') - .one('click', function () { - thread.markAsRead(); - }); - } else if ( - !self._areThreadWindowsHidden() && - !thread.isFolded() + threadWindow.scrollToBottom(); + if ( + !self._areAllThreadWindowsHidden() && + !thread.isFolded() && + !threadWindow.isPassive() ) { thread.markAsRead(); } @@ -170,6 +132,8 @@ MailManager.include({ * @param {integer|string} threadID * @param {Object} options option to be applied on opening thread window, if * the thread is detached + * @param {boolean} [options.passively=false] if set, the window will behave + * passively. */ updateThreadWindow: function (threadID, options) { var thread = this.getThread(threadID); @@ -198,14 +162,14 @@ MailManager.include({ this._threadWindows.splice(this._availableSlotsForThreadWindows-1, 0, threadWindow); }, /** - * States whether the thread windows are hidden or not. + * States whether all the thread windows are hidden or not. * When discuss is open, the thread windows are hidden. * * @private * @returns {boolean} */ - _areThreadWindowsHidden: function () { - return this._isDiscussOpen(); + _areAllThreadWindowsHidden: function () { + return $(this.THREAD_WINDOW_APPENDTO).hasClass('o_no_thread_window'); }, /** * Close the thread window linked to the thread with ID `threadID`. @@ -224,6 +188,15 @@ MailManager.include({ threadWindow.destroy(); } }, + /** + * Close the blank thread window. + */ + _closeBlankThreadWindow: function () { + var blankThreadWindow = this._getBlankThreadWindow(); + if (blankThreadWindow) { + this._closeThreadWindow(blankThreadWindow.getID()); + } + }, /** * Compute the number of available slots to display thread windows on the * screen. This is based on the width of the screen, and the width of a @@ -256,8 +229,8 @@ MailManager.include({ * Get the blank thread window, which is the special thread window that has * no thread linked to it. * - * This is useful in case a DM window may replace the blank thread window, - * when we want to open a DM from the blank thread window. + * This is useful in case a DM chat window may replace the blank thread + * window, when we want to open a DM chat from the blank thread window. * * @private * @returns {mail.ThreadWindow|undefined} the "blank thread" window, @@ -298,6 +271,40 @@ MailManager.include({ return threadWindow.getID() === threadID; }); }, + /** + * @override + * @private + */ + _initializeInternalState: function () { + this._super.apply(this, arguments); + + this._availableSlotsForThreadWindows = 0; + this._hiddenThreadWindows = []; + // used to keep dropdown open when closing thread windows + this._keepHiddenThreadWindowsDropdownOpen = false; + this._spaceLeftForThreadWindows = 0; + this._threadWindows = []; + // jQuery element for the dropdown of hidden thread windows + // see _renderHiddenThreadWindowsDropdown + this._$hiddenThreadWindowsDropdown = null; + }, + /** + * @override + * @private + */ + _listenOnBuses: function () { + this._super.apply(this, arguments); + this._mailBus + .on('update_message', this, this._onUpdateMessage) + .on('new_message', this, this._onNewMessage) + .on('new_channel', this, this._onNewChannel) + .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible) + .on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel) + .on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter) + .on('update_dm_presence', this, this._onUpdateDmPresence); + + core.bus.on('resize', this, _.debounce(this._repositionThreadWindows.bind(this), 100)); + }, /** * Make the hidden thread window dropdown menu, that is render it and set * event listener on this dropdown menu DOM element. @@ -371,16 +378,16 @@ MailManager.include({ threadWindow.toggleFold(false); }, /** - * Open and detach the DM in a thread window. + * Open and detach the DM chat in a thread window. * - * This method assumes that no such DM exists locally, so it is kind of a - * "create DM and open DM window" operation + * This method assumes that no such DM chat exists locally, so it is kind + * of a "create DM chat and open DM chat window" operation * * @private * @param {integer} partnerID - * @returns {$.Promise} resolved with ID of the dm channel + * @returns {$.Promise} resolved with ID of the DM chat */ - _openAndDetachDm: function (partnerID) { + _openAndDetachDMChat: function (partnerID) { return this._rpc({ model: 'mail.channel', method: 'channel_get_and_minimize', @@ -497,7 +504,7 @@ MailManager.include({ * @private */ _repositionThreadWindows: function () { - if (this._areThreadWindowsHidden()) { + if (this._areAllThreadWindowsHidden()) { return; } this._computeAvailableSlotsForThreadWindows(this._threadWindows.length); @@ -532,28 +539,21 @@ MailManager.include({ * * @private * @param {mail.model.Message} message - * @param {boolean} [scrollBottom=false] if set, thread windows with this - * message should scroll to the bottom if the message is visible + * @param {Object} options + * @param {boolean} [options.keepBottom=false] if set, thread windows with + * this message should scroll to the bottom if their bottoms are currently + * visible. + * @param {boolean} [options.passively=false] if set, thread windows with + * this message become passive, so that they are marked as read only when + * the focus is on the thread window. */ - _updateThreadWindows: function (message, scrollBottom) { - var self = this; + _updateThreadWindowsFromMessage: function (message, options) { + if (this._areAllThreadWindowsHidden()) { + return; + } _.each(this._threadWindows, function (threadWindow) { if (_.contains(message.getThreadIDs(), threadWindow.getID())) { - var thread = self.getThread(threadWindow.getID()); - var messageVisible = !self._areThreadWindowsHidden() && - !threadWindow.isFolded() && - !threadWindow.isHidden() && - threadWindow.threadWidget.isAtBottom(); - if (messageVisible && !threadWindow.isPassive()) { - thread.markAsRead(); - } - thread.fetchMessages() - .then(function () { - threadWindow.render(); - if (scrollBottom && messageVisible) { - threadWindow.threadWidget.scrollToBottom(); - } - }); + threadWindow.update(options); } }); }, @@ -562,6 +562,14 @@ MailManager.include({ // Handlers //-------------------------------------------------------------------------- + /** + * Called when manually closing the blank thread window. + * + * @private + */ + _onCloseBlankThreadWindow: function () { + this._closeBlankThreadWindow(); + }, /** * @private * @param {boolean} open @@ -579,21 +587,21 @@ MailManager.include({ /** * Called when someone asks window manager whether the bottom of `thread` is * visible or not. An object `query` is provided in order to reponse on the - * key `isDisplayed`. + * key `isVisible`. * * @private * @param {mail.model.Thread} thread * @param {Object} query - * @param {boolean} query.isBottomVisible write on it + * @param {boolean} query.isVisible write on it */ _onIsThreadBottomVisible: function (thread, query) { _.each(this._threadWindows, function (threadWindow) { if ( thread.getID() === threadWindow.getID() && - threadWindow.threadWidget.isAtBottom() && + threadWindow.isAtBottom() && !threadWindow.isHidden() ) { - query.isBottomVisible = true; + query.isVisible = true; } }); }, @@ -617,7 +625,7 @@ MailManager.include({ * @param {Object} message */ _onNewMessage: function (message) { - this._updateThreadWindows(message, true); + this._updateThreadWindowsFromMessage(message, { keepBottom: true, passively: true }); }, /** * Close the thread window when unsusbscribe from a channel. @@ -677,7 +685,7 @@ MailManager.include({ * @param {Object} message */ _onUpdateMessage: function (message) { - this._updateThreadWindows(message, false); + this._updateThreadWindowsFromMessage(message, { keepBottom: false }); }, }); diff --git a/addons/mail/static/src/js/systray/systray_activity_menu.js b/addons/mail/static/src/js/systray/systray_activity_menu.js index d65bb7ff5db..900d3610042 100644 --- a/addons/mail/static/src/js/systray/systray_activity_menu.js +++ b/addons/mail/static/src/js/systray/systray_activity_menu.js @@ -132,9 +132,7 @@ var ActivityMenu = Widget.extend({ }); }, /** - * When menu clicked update activity preview if counter updated * @private - * @param {MouseEvent} event */ _onActivityMenuClick: function () { if (!this._isOpen()) { diff --git a/addons/mail/static/src/js/systray/systray_messaging_menu.js b/addons/mail/static/src/js/systray/systray_messaging_menu.js index 5552a80393e..600570e3584 100644 --- a/addons/mail/static/src/js/systray/systray_messaging_menu.js +++ b/addons/mail/static/src/js/systray/systray_messaging_menu.js @@ -7,6 +7,8 @@ var SystrayMenu = require('web.SystrayMenu'); var Widget = require('web.Widget'); var QWeb = core.qweb; +var _t = core._t; + /** * Menu item appended in the systray part of the navbar * @@ -252,6 +254,7 @@ var MessagingMenu = Widget.extend({ // we are opening the dropdown so update its content this._updatePreviews(); } + this._updatePreviews(); }, /** * @private diff --git a/addons/mail/static/src/js/thread_field.js b/addons/mail/static/src/js/thread_field.js index a239481931b..a6609ce6d73 100644 --- a/addons/mail/static/src/js/thread_field.js +++ b/addons/mail/static/src/js/thread_field.js @@ -37,7 +37,7 @@ var ThreadField = AbstractField.extend({ this.dp = new concurrency.DropPrevious(); this._threadWidget = new ThreadWidget(this, { - displayOrder: ThreadWidget.ORDER.DESC, + displayOrder: ThreadWidget.ORDER.ASC, displayDocumentLinks: false, displayMarkAsRead: false, squashCloseMessages: false, diff --git a/addons/mail/static/src/js/thread_widget.js b/addons/mail/static/src/js/thread_widget.js index ab0dea1bece..c2d642d5dbe 100644 --- a/addons/mail/static/src/js/thread_widget.js +++ b/addons/mail/static/src/js/thread_widget.js @@ -12,8 +12,8 @@ var QWeb = core.qweb; var _t = core._t; var ORDER = { - ASC: 1, - DESC: -1, + ASC: 1, // visually, chronological order of thread from bottom to top + DESC: -1, // visually, chronological order of thread from top to bottom }; var READ_MORE = _t("read more"); @@ -55,7 +55,7 @@ var ThreadWidget = Widget.extend({ // options when the thread is enabled (e.g. can send message, // interact on messages, etc.) this._enabledOptions = _.defaults(options || {}, { - displayOrder: ORDER.ASC, + displayOrder: ORDER.DESC, displayMarkAsRead: true, displayStars: true, displayDocumentLinks: true, @@ -78,20 +78,16 @@ var ThreadWidget = Widget.extend({ loadMoreOnScroll: this._enabledOptions.loadMoreOnScroll, }; this._selectedMessageID = null; + this._currentThreadID = null; }, - /** * @override */ destroy: function () { clearInterval(this._updateTimestampsInterval); }, - /** - * @param {mail.model.AbstractMessage[]} messages list of messages of the - * thread, ordered by increasing IDs (a higher ID means a more recent - * message). - * @param {} + * @param {mail.model.AbstractThread} thread the thread to render. * @param {Object} [options] * @param {integer} [options.displayOrder=ORDER.ASC] order of displaying * messages in the thread: @@ -99,11 +95,18 @@ var ThreadWidget = Widget.extend({ * - ORDER.DESC: last message is at the top of the thread * @param {boolean} [options.displayLoadMore] * @param {boolean} [options.isCreateMode] + * @param {boolean} [options.scrollToBottom=false] * @param {boolean} [options.squashCloseMessages] */ render: function (thread, options) { var self = this; + var shouldScrollToBottomAfterRendering = false; + if (this._currentThreadID === thread.getID() && this.isAtBottom()) { + shouldScrollToBottomAfterRendering = true; + } + this._currentThreadID = thread.getID(); + // copy so that reverse do not alter order in the thread object var messages = _.clone(thread.getMessages()); @@ -115,9 +118,6 @@ var ThreadWidget = Widget.extend({ return message.getAttachments(); }))); - if (modeOptions.displayOrder === ORDER.DESC) { - messages.reverse(); - } options = _.extend({}, modeOptions, options, { selectedMessageID: this._selectedMessageID, }); @@ -160,6 +160,11 @@ var ThreadWidget = Widget.extend({ } prevMessage = message; }); + + if (modeOptions.displayOrder === ORDER.DESC) { + messages.reverse(); + } + this.$el.html(QWeb.render('mail.widget.Thread', { thread: thread, displayAuthorMessages: displayAuthorMessages, @@ -175,6 +180,10 @@ var ThreadWidget = Widget.extend({ self._insertReadMore($message); }); + if (shouldScrollToBottomAfterRendering) { + this.scrollToBottom(); + } + if (!this._updateTimestampsInterval) { this.updateTimestampsInterval = setInterval(function () { self._updateTimestamps(); @@ -368,11 +377,6 @@ var ThreadWidget = Widget.extend({ }); }); }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - /** * @private * @param {Object} options diff --git a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js index 8ec8aa84b31..ffed0e16a1f 100644 --- a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js +++ b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js @@ -33,10 +33,10 @@ var AbstractThreadWindow = Widget.extend({ events: { 'click .o_thread_window_close': '_onClickClose', 'click .o_thread_window_title': '_onClickFold', - 'click .o_thread_composer': '_onComposerClick', + 'click .o_composer_text_field': '_onComposerClick', 'click .o_mail_thread': '_onThreadWindowClicked', - 'keydown .o_thread_composer': '_onKeydown', - 'keypress .o_thread_composer': '_onKeypress', + 'keydown .o_composer_text_field': '_onKeydown', + 'keypress .o_composer_text_field': '_onKeypress', }, /** * Children of this class must make use of `thread`, which is an object that @@ -50,6 +50,7 @@ var AbstractThreadWindow = Widget.extend({ * thread window is linked to. If not set, it is the "blank" thread * window. * @param {Object} [options={}] + * @param {mail.model.AbstractThread} [options.thread] */ init: function (parent, thread, options) { this._super(parent); @@ -65,16 +66,19 @@ var AbstractThreadWindow = Widget.extend({ this._hidden = false; this._thread = thread || null; + this._debouncedOnScroll = _.debounce(this._onScroll.bind(this), 100); + if (!this.hasThread()) { // internal fold state of thread window without any thread this._folded = false; } }, start: function () { + var self = this; this.$input = this.$('.o_composer_text_field'); this.$header = this.$('.o_thread_window_header'); - this.threadWidget = new ThreadWidget(this, { + this._threadWidget = new ThreadWidget(this, { displayDocumentLinks: false, displayMarkAsRead: false, displayStars: this.options.displayStars, @@ -89,7 +93,9 @@ var AbstractThreadWindow = Widget.extend({ var margin_dir = _t.database.parameters.direction === "rtl" ? "margin-left" : "margin-right"; this.$el.css(margin_dir, $.position.scrollbarWidth()); } - var def = this.threadWidget.replace(this.$('.o_thread_window_content')); + var def = this._threadWidget.replace(this.$('.o_thread_window_content')).then(function () { + self._threadWidget.$el.on('scroll', self, self._debouncedOnScroll); + }); return $.when(this._super(), def); }, /** @@ -125,8 +131,9 @@ var AbstractThreadWindow = Widget.extend({ */ close: function () {}, /** - * Get the status of the thread, such as the im status of a DM ('online', - * 'offline', etc.). If this window has no thread, returns `undefined`. + * Get the status of the thread, such as the im status of a DM chat + * ('online', 'offline', etc.). If this window has no thread, returns + * `undefined`. * * @returns {string|undefined} */ @@ -172,6 +179,15 @@ var AbstractThreadWindow = Widget.extend({ hasThread: function () { return !! this._thread; }, + /** + * Tells whether the bottom of the thread in the thread window is visible + * or not. + * + * @returns {boolean} + */ + isAtBottom: function () { + return this._threadWidget.isAtBottom(); + }, /** * State whether the related thread is folded or not. If there are no * thread related to this window, it means this is the "blank" thread @@ -202,13 +218,22 @@ var AbstractThreadWindow = Widget.extend({ isHidden: function () { return this._hidden; }, + /** + * States whether the input of the thread window should be displayed or not. + * By default, any thread window with a thread needs a composer. + * + * @returns {boolean} + */ + needsComposer: function () { + return this.hasThread(); + }, /** * Render the thread window */ render: function () { this.renderHeader(); if (this.hasThread()) { - this.threadWidget.render(this._thread, { displayLoadMore: false }); + this._threadWidget.render(this._thread, { displayLoadMore: false }); } }, /** @@ -223,6 +248,12 @@ var AbstractThreadWindow = Widget.extend({ this.$header.html( QWeb.render('mail.AbstractThreadWindow.HeaderContent', options)); }, + /** + * Scroll to the bottom of the thread in the thread window + */ + scrollToBottom: function () { + this._threadWidget.scrollToBottom(); + }, /** * Toggle the fold state of this thread window. Also update the fold state * of the thread model. If the boolean parameter `folded` is provided, it @@ -244,7 +275,7 @@ var AbstractThreadWindow = Widget.extend({ */ updateVisualFoldState: function () { if (!this.isFolded()) { - this.threadWidget.scrollToBottom(); + this._threadWidget.scrollToBottom(); this._focusInput(); } this._animateFold(); @@ -311,13 +342,22 @@ var AbstractThreadWindow = Widget.extend({ return this._thread.getID(); }, /** - * Post a message on this thread window + * Post a message on this thread window, and auto-scroll to the bottom of + * the thread. * - * @abstract * @private * @param {Object} messageData */ - _postMessage: function (messageData) {}, + _postMessage: function (messageData) { + var self = this; + if (!this.hasThread()) { + return; + } + this._thread.postMessage(messageData) + .then(function () { + self._threadWidget.scrollToBottom(); + }); + }, /** * Update the fold state of the thread. * This function is called when toggling the fold state of this window. @@ -334,6 +374,13 @@ var AbstractThreadWindow = Widget.extend({ this._folded = folded; } }, + /** + * Warn other mail components that the unread counter has been updated + * + * @abstract + * @private + */ + _warnUpdatedUnreadCounter: function () {}, //-------------------------------------------------------------------------- // Handlers @@ -416,6 +463,14 @@ var AbstractThreadWindow = Widget.extend({ _onKeypress: function (ev) { ev.stopPropagation(); // to prevent jquery's blockUI to cancel event }, + /** + * @private + */ + _onScroll: function () { + if (this.hasThread() && this.isAtBottom()) { + this._thread.markAsRead(); + } + }, /** * When a thread window is clicked on, we want to give the focus to the main * input. An exception is made when the user is selecting something. diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js index 1ff9417d127..1d38ee338d3 100644 --- a/addons/mail/static/src/js/thread_windows/thread_window.js +++ b/addons/mail/static/src/js/thread_windows/thread_window.js @@ -16,7 +16,10 @@ var _t = core._t; */ var ThreadWindow = AbstractThreadWindow.extend({ template: 'mail.ThreadWindow', + custom_events: AbstractThreadWindow.prototype.custom_events, events: _.extend({}, AbstractThreadWindow.prototype.events, { + 'click .o_mail_thread': '_onThreadWindowFocus', + 'click .o_thread_composer': '_onThreadWindowFocus', 'click .o_thread_window_expand': '_onClickExpand', }), /** @@ -25,7 +28,8 @@ var ThreadWindow = AbstractThreadWindow.extend({ * @override * @param {mail.Manager} parent * @param {mail.model.Thread} [thread = null] if not set, this is a "blank" - * thread window. It lets us open a DM by providing the name of a chat. + * thread window. It lets us open a DM chat by providing the name of a + * user. * @param {Object} [options={}] * @param {boolean} [options.passively=false] */ @@ -34,11 +38,11 @@ var ThreadWindow = AbstractThreadWindow.extend({ // don't automatically mark unread messages as seen when at the bottom // of the thread - this._passive = this.options.passively; + this._passive = this.options.passively || false; if (!this.hasThread()) { // remembered partner ID of "blank" thread window in order to be - // replaced with newly opened DM window + // replaced with newly opened DM chat window this.directPartnerID = null; } }, @@ -54,10 +58,11 @@ var ThreadWindow = AbstractThreadWindow.extend({ var composerDef; if (!this.hasThread()) { this._startWithoutThread(); - } else if (this._needsComposer()) { + } else if (this.needsComposer()) { var basicComposer = new BasicComposer(this, { mentionPartnersRestricted: this._thread.getType() !== 'document_thread', - isMini: true + isMini: true, + thread: this._thread, }); basicComposer.on('post_message', this, this._postMessage); basicComposer.once('input_focused', this, function () { @@ -85,6 +90,8 @@ var ThreadWindow = AbstractThreadWindow.extend({ close: function () { if (this.hasThread()) { this._thread.close(); + } else { + this.trigger_up('close_blank_thread_window'); } }, /** @@ -124,6 +131,24 @@ var ThreadWindow = AbstractThreadWindow.extend({ isPassive: function () { return this._passive; }, + /** + * States whether the input of the thread window should be displayed or not. + * This is based on the type of the thread: + * + * Do not display the input in the following cases: + * + * - no thread related to this window + * - window of a mailbox (temp: let us have mailboxes in window mode) + * - window of a thread with mass mailing + * + * Any other threads show the input in the window. + * + * @override + * @returns {boolean} + */ + needsComposer: function () { + return this._super() && !this._thread.isMassMailing(); + }, /** * Turn the thread window in active mode, so that when the bottom of the * thread is visible, it is automatically marked as read. @@ -131,6 +156,42 @@ var ThreadWindow = AbstractThreadWindow.extend({ removePassive: function () { this._passive = false; }, + /** + * Set the thread window in passive mode, so that new received message will + * keep the thread window as unread until there is focus on the thread + * window. + */ + setPassive: function () { + this._passive = true; + }, + /** + * Update this thread window + * + * @param {Object} options + * @param {boolean} [options.keepBottom=false] if set, this thread window + * should scroll to the bottom if it was at the bottom before update + * @param {boolean} [options.passively=false] if set, this thread window + * becomes passive, so that it is marked as read only when the focus is + * on it. + */ + update: function (options) { + var self = this; + if (options.passively) { + this.setPassive(); + } + var bottomVisible = !this.isFolded() && + !this.isHidden() && + this.isAtBottom(); + if (bottomVisible && !this.isPassive()) { + this._thread.markAsRead(); + } + this._thread.fetchMessages().then(function () { + self.render(); + if (bottomVisible && options.keepBottom) { + self.scrollToBottom(); + } + }); + }, //-------------------------------------------------------------------------- // Private @@ -155,31 +216,11 @@ var ThreadWindow = AbstractThreadWindow.extend({ * @private */ _listenThreadWidget: function () { - this.threadWidget + this._threadWidget .on('redirect', this, this._onRedirect) .on('redirect_to_channel', this, this._onRedirectToChannel) .on('toggle_star_status', this, this._onToggleStarStatus); }, - /** - * States whether the input of the thread window should be displayed or not. - * This is based on the type of the thread: - * - * Do not display the input in the following cases: - * - * - no thread related to this window - * - window of a mailbox (temp: let us have mailboxes in window mode) - * - window of a thread with mass mailing - * - * Any other threads show the input in the window. - * - * @private - * @returns {boolean} - */ - _needsComposer: function () { - return this.hasThread() && - (this._thread.getType() !== 'mailbox') && - !this._thread.isMassMailing(); - }, /** * Open this thread window. * This private method exists only for the purpose of providing a callback @@ -190,24 +231,6 @@ var ThreadWindow = AbstractThreadWindow.extend({ _open: function () { this.call('mail_service', 'openThreadWindow', this.getID()); }, - /** - * Post a message in the thread related to this window. - * If this window has no thread, do nothing - * - * @override - * @private - * @param {Object} messageData - */ - _postMessage: function (messageData) { - var self = this; - if (!this.hasThread()) { - return; - } - this._thread.postMessage(messageData) - .then(function () { - self.threadWidget.scrollToBottom(); - }); - }, /** * @private */ @@ -222,10 +245,10 @@ var ThreadWindow = AbstractThreadWindow.extend({ }, select: function (event, ui) { // remember partner ID so that we can replace this window - // with new DM window + // with new DM chat window var partnerID = ui.item.id; self.directPartnerID = partnerID; - self.call('mail_service', 'openDmWindow', partnerID); + self.call('mail_service', 'openDMChatWindowFromBlankThreadWindow', partnerID); }, }) .focus(); @@ -248,7 +271,7 @@ var ThreadWindow = AbstractThreadWindow.extend({ _onClickExpand: _.debounce(function (ev) { var self = this; ev.preventDefault(); - if (this._thread.getType() === 'document_thread') { + if (this.hasThread() && this._thread.getType() === 'document_thread') { this.do_action({ type: 'ir.actions.act_window', res_model: this._thread.getDocumentModel(), @@ -305,6 +328,30 @@ var ThreadWindow = AbstractThreadWindow.extend({ this.toggleFold(false); } }, + /** + * Override it so that passive thread windows do not mark thread as read + * on scroll + * + * @override + * @private + */ + _onScroll: function () { + if (this.isPassive()) { + return; + } + return this._super.apply(this, arguments); + }, + /** + * Called when focusing the thread window (click on thread or composer) + * + * @private + */ + _onThreadWindowFocus: function () { + if (this.isPassive()) { + this.removePassive(); + this._thread.markAsRead(); + } + }, /** * @private * @param {integer} messageID diff --git a/addons/mail/static/src/scss/abstract_thread_window.scss b/addons/mail/static/src/scss/abstract_thread_window.scss index 127b7b8adaf..9f0293ac3c0 100644 --- a/addons/mail/static/src/scss/abstract_thread_window.scss +++ b/addons/mail/static/src/scss/abstract_thread_window.scss @@ -75,6 +75,7 @@ $o-mail-thread-window-zindex: $zindex-modal + 1 !default; flex: 1 1 auto; overflow: auto; -webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari) + .o_thread_date_separator { margin: 0px 0px 15px 0px; .o_thread_date { diff --git a/addons/mail/static/src/scss/discuss.scss b/addons/mail/static/src/scss/discuss.scss index cf037edd6f2..a1ae5482c65 100644 --- a/addons/mail/static/src/scss/discuss.scss +++ b/addons/mail/static/src/scss/discuss.scss @@ -127,7 +127,6 @@ .o_mail_thread { flex: 1 0 0; overflow: auto; - padding: 0 0 15px 0; -webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari) } diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss index b724c7fb241..12bd5a3af70 100644 --- a/addons/mail/static/src/scss/thread.scss +++ b/addons/mail/static/src/scss/thread.scss @@ -3,6 +3,37 @@ text-align: center; } + .o_thread_bottom_free_space { + height: 15px; + } + + .o_thread_tooltip_container { + display: inline; + position: relative; + + .o_thread_tooltip_content { + top: 10px; + background: $gray-lighter; + padding: 4px; + border: solid $gray-lighter 1px; + border-radius: 5px; + color: gray; + width: 300px; + display: none; + opacity: 0; + position: absolute; + transition: opacity 0.5s; + i.fa-exclamation { + color: red; + } + } + + .o_thread_tooltip:hover + .o_thread_tooltip_content { + display: inline; + opacity: 1; + } + } + .o_thread_date_separator { margin-top: 15px; margin-bottom: 30px; diff --git a/addons/mail/static/src/xml/abstract_thread_window.xml b/addons/mail/static/src/xml/abstract_thread_window.xml index e62cdba042b..af72d5dd36c 100644 --- a/addons/mail/static/src/xml/abstract_thread_window.xml +++ b/addons/mail/static/src/xml/abstract_thread_window.xml @@ -15,7 +15,7 @@
-
+
diff --git a/addons/mail/static/src/xml/discuss.xml b/addons/mail/static/src/xml/discuss.xml index ad9a809ba03..eb03e3c0c55 100644 --- a/addons/mail/static/src/xml/discuss.xml +++ b/addons/mail/static/src/xml/discuss.xml @@ -40,7 +40,7 @@ -
Moderation Queue @@ -58,7 +58,7 @@ Add a channel - + Direct Messages @@ -133,7 +133,7 @@
- +
@@ -221,7 +221,7 @@ Inbox
-
+
Chat
@@ -246,7 +246,7 @@
# Add a channel - Open chat + Open chat
@@ -266,7 +266,7 @@ @param {string} [preview.documentModel] @param {string|integer} preview.id @param {string} [preview.imageSRC] - @param {boolean} [preview.isAuthor] + @param {boolean} [preview.isMyselfAuthor] @param {moment} [preview.date] @param {string} [preview.author] @param {string} [preview.body] @@ -293,7 +293,7 @@
- + You: diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml index b3443418c2b..b33452382ac 100644 --- a/addons/mail/static/src/xml/thread.xml +++ b/addons/mail/static/src/xml/thread.xml @@ -3,22 +3,13 @@ - - - @@ -134,14 +125,90 @@
- + + + + + + + +
+ + + + +
+
+ + + +
+ + + +
+ +
+
+ + + -
@@ -261,7 +328,7 @@ - + diff --git a/addons/mail/static/tests/discuss_mobile_tests.js b/addons/mail/static/tests/discuss_mobile_tests.js index 82bcad30fe0..a0b9383ea45 100644 --- a/addons/mail/static/tests/discuss_mobile_tests.js +++ b/addons/mail/static/tests/discuss_mobile_tests.js @@ -47,9 +47,9 @@ QUnit.test('mobile basic rendering', function (assert) { "should be in inbox"); // move to DMs tab - discuss.$('.o_mail_mobile_tab[data-type=dm]').click(); - assert.ok(discuss.$('.o_mail_mobile_tab[data-type=dm]').hasClass('active'), - "should be in DMs tab"); + discuss.$('.o_mail_mobile_tab[data-type=dm_chat]').click(); + assert.ok(discuss.$('.o_mail_mobile_tab[data-type=dm_chat]').hasClass('active'), + "should be in DMs chat tab"); assert.strictEqual(discuss.$('.o_mail_discuss_content .o_mail_no_content').length, 0, "should display the no content message"); $('.o_mail_discuss_button_dm').click(); // click to add a channel diff --git a/addons/mail/static/tests/discuss_moderation_tests.js b/addons/mail/static/tests/discuss_moderation_tests.js index e324decba8c..505cb22aed4 100644 --- a/addons/mail/static/tests/discuss_moderation_tests.js +++ b/addons/mail/static/tests/discuss_moderation_tests.js @@ -6,7 +6,7 @@ var mailTestUtils = require('mail.testUtils'); var createDiscuss = mailTestUtils.createDiscuss; QUnit.module('mail', {}, function () { -QUnit.module('Discuss moderation', { +QUnit.module('Discuss (Moderation)', { beforeEach: function () { // patch _.debounce and _.throttle to be fast and synchronous this.underscoreDebounce = _.debounce; @@ -196,7 +196,7 @@ QUnit.test('moderator: moderated channel with pending moderation message', funct assert.strictEqual($(moderateAllSelector + '[data-decision="accept"]').length, 1, "there should one moderate button to accept messages pending moderation"); assert.strictEqual($(moderateAllSelector + '[data-decision="accept"]').attr('style'), - 'display: none;', 'the moderate button "Accept" should be invisible by default'); + 'display: none', 'the moderate button "Accept" should be invisible by default'); assert.strictEqual($(moderateAllSelector + '[data-decision="reject"]').length, 1, "there should one moderate button to reject messages pending moderation"); assert.strictEqual($(moderateAllSelector + '[data-decision="reject"]').attr('style'), @@ -217,11 +217,11 @@ QUnit.test('moderator: moderated channel with pending moderation message', funct "the 'Unselect All' button should not be disabled"); // check moderate all buttons updated (visible) assert.strictEqual($(moderateAllSelector + '[data-decision="accept"]').attr('style'), - 'display: inline-block;', 'the moderate button "Accept" should become visible'); + 'display: inline-block', 'the moderate button "Accept" should become visible'); assert.strictEqual($(moderateAllSelector + '[data-decision="reject"]').attr('style'), - 'display: inline-block;', 'the moderate button "Reject" should become visible'); + 'display: inline-block', 'the moderate button "Reject" should become visible'); assert.strictEqual($(moderateAllSelector + '[data-decision="discard"]').attr('style'), - 'display: inline-block;', 'the moderate button "Discard" should become visible'); + 'display: inline-block', 'the moderate button "Discard" should become visible'); // 2. go to channel 'general' discuss.$('.o_mail_discuss_item[data-thread-id="1"]').click(); diff --git a/addons/mail/static/tests/discuss_tests.js b/addons/mail/static/tests/discuss_tests.js index 57a8c00ab26..6f605d24680 100644 --- a/addons/mail/static/tests/discuss_tests.js +++ b/addons/mail/static/tests/discuss_tests.js @@ -11,7 +11,6 @@ var testUtils = require('web.test_utils'); var createDiscuss = mailTestUtils.createDiscuss; QUnit.module('mail', {}, function () { - QUnit.module('Discuss', { beforeEach: function () { // patch _.debounce and _.throttle to be fast and synchronous @@ -181,7 +180,7 @@ QUnit.test('@ mention in channel', function (assert) { body: args.kwargs.body, channel_ids: [1], }; - var notification = [[false, 'mail.channel'], data]; + var notification = [[false, 'mail.channel', 1], data]; objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]); receiveMessageDef.resolve(); return $.when(42); @@ -204,7 +203,6 @@ QUnit.test('@ mention in channel', function (assert) { var $input = discuss.$('textarea.o_composer_text_field').first(); assert.ok($input.length, "should display a composer input"); - // Simulate '@' typed by user with mocked Window.getSelection // Note: focus is needed in order to trigger rpc 'channel_fetch_listeners' $input.focus(); $input.val("@"); @@ -309,7 +307,6 @@ QUnit.test('@ mention in channel', function (assert) { assert.strictEqual(discuss.$('.o_thread_message_content a').text(), "@Admin", "should have correct mention link in the message content"); - // Restore window.getSelection discuss.destroy(); done(); }); @@ -360,7 +357,7 @@ QUnit.test('no crash focusout emoji button', function (assert) { }); QUnit.test('older messages are loaded on scroll', function (assert) { - assert.expect(3); + assert.expect(10); var done = assert.async(); var fetchCount = 0; @@ -394,10 +391,15 @@ QUnit.test('older messages are loaded on scroll', function (assert) { services: this.services, mockRPC: function (route, args) { if (args.method === 'message_fetch') { + assert.step(args.method); fetchCount++; // 1st fetch: inbox initial fetch // 2nd fetch: general initial fetch // 3rd fetch: general load more + if (fetchCount === 1) { + assert.strictEqual(args.kwargs.limit, 30, + "should ask to fetch 30 messages at most"); + } if (fetchCount === 3) { loadMoreDef.resolve(); } @@ -405,6 +407,10 @@ QUnit.test('older messages are loaded on scroll', function (assert) { return this._super.apply(this, arguments); }, }).then(function (discuss) { + + assert.verifySteps(['message_fetch'], + "should fetch messages once for needaction messages (Inbox)"); + var $general = discuss.$('.o_mail_discuss_item[data-thread-id=1]'); assert.strictEqual($general.length, 1, "should have a channel item with id 1"); @@ -412,6 +418,9 @@ QUnit.test('older messages are loaded on scroll', function (assert) { // switch to 'general' $general.click(); + assert.verifySteps(['message_fetch', 'message_fetch'], + "should fetch a second time for general channel messages (30 last messages)"); + assert.strictEqual(discuss.$('.o_thread_message').length, 30, "should display the 30 messages"); @@ -421,6 +430,8 @@ QUnit.test('older messages are loaded on scroll', function (assert) { loadMoreDef .then(concurrency.delay.bind(concurrency, 0)) .then(function () { + assert.verifySteps(['message_fetch', 'message_fetch', 'message_fetch'], + "should fetch a third time for general channel messages (5 remaining messages)"); assert.strictEqual(discuss.$('.o_thread_message').length, 35, "all messages should now be loaded"); @@ -621,7 +632,6 @@ QUnit.test('confirm dialog when administrator leave (not chat) channel', functio discuss.destroy(); done(); }); - }); QUnit.test('convert emoji sources to unicodes on message_post', function (assert) { @@ -657,7 +667,7 @@ QUnit.test('convert emoji sources to unicodes on message_post', function (assert body: args.kwargs.body, channel_ids: [1], }; - var notification = [[false, 'mail.channel'], data]; + var notification = [[false, 'mail.channel', 1], data]; objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]); receiveMessageDef.resolve(); return $.when(42); @@ -691,6 +701,6 @@ QUnit.test('convert emoji sources to unicodes on message_post', function (assert }); }); }); -}); }); +}); diff --git a/addons/mail/static/tests/document_thread_window_tests.js b/addons/mail/static/tests/document_thread_window_tests.js index 14d50d593ba..ec6f97f0b49 100644 --- a/addons/mail/static/tests/document_thread_window_tests.js +++ b/addons/mail/static/tests/document_thread_window_tests.js @@ -8,7 +8,7 @@ var testUtils = require('web.test_utils'); QUnit.module('mail', {}, function () { -QUnit.module('DocumentThreadWindow', { +QUnit.module('ThreadWindow (Document Thread)', { beforeEach: function () { var partnerID = 44; this.data = { diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js index 16b588b07ec..cff4917465a 100644 --- a/addons/mail/static/tests/helpers/mock_server.js +++ b/addons/mail/static/tests/helpers/mock_server.js @@ -155,6 +155,9 @@ MockServer.include({ if (args.method === 'channel_fetch_preview') { return $.when(this._mockChannelFetchPreview(args)); } + if (args.method === 'channel_minimize') { + return $.when(); + } if (args.method === 'channel_seen') { return $.when(); } @@ -173,7 +176,7 @@ MockServer.include({ if (args.method === 'moderate') { return $.when(this._mockModerate(args)); } - if (args.method === 'channel_minimize') { + if (args.method === 'set_message_done') { return $.when(); } return this._super(route, args); diff --git a/addons/mail/static/tests/helpers/test_utils.js b/addons/mail/static/tests/helpers/test_utils.js index a6b0b67a037..033866522e6 100644 --- a/addons/mail/static/tests/helpers/test_utils.js +++ b/addons/mail/static/tests/helpers/test_utils.js @@ -6,7 +6,6 @@ var BusService = require('bus.BusService'); var Discuss = require('mail.Discuss'); var MailService = require('mail.Service'); -var AbstractService = require('web.AbstractService'); var AbstractStorageService = require('web.AbstractStorageService'); var Class = require('web.Class'); var ControlPanel = require('web.ControlPanel'); @@ -67,6 +66,7 @@ var MockMailService = Class.extend({ return BusService.extend({ _poll: function () {}, // Do nothing isOdooFocused: function () { return true; }, + updateOption: function () {}, }); }, mail_service: function () { diff --git a/addons/mail/static/tests/thread_window_tests.js b/addons/mail/static/tests/thread_window_tests.js index a10bed3f1b0..d8faa8d6a37 100644 --- a/addons/mail/static/tests/thread_window_tests.js +++ b/addons/mail/static/tests/thread_window_tests.js @@ -8,9 +8,14 @@ var testUtils = require('web.test_utils'); var Widget = require('web.Widget'); QUnit.module('mail', { - beforeEach: function () { + beforeEach: function (assert) { var self = this; + this.BEFORE_EACH_ASSERTIONS_NUM = 1; + + assert.strictEqual($('.o_thread_window').length, 0, + "should have no thread windows open before the test"); + // define channel to link to chat window this.data = { 'mail.message': { @@ -34,6 +39,8 @@ QUnit.module('mail', { var widget = new Widget(); // in non-debug mode, append thread windows in qunit-fixture + // note that it does not hide thread window because it uses fixed + // position, and qunit-fixture uses absolute... if (params.debug) { self.services.mail_service.prototype.THREAD_WINDOW_APPENDTO = 'body'; } else { @@ -50,10 +57,9 @@ QUnit.module('mail', { }, }, function () { - QUnit.module('thread_window'); - + QUnit.module('ThreadWindow'); QUnit.test('close thread window using ESCAPE key', function (assert) { - assert.expect(5); + assert.expect(5 + this.BEFORE_EACH_ASSERTIONS_NUM); var parent = this.createParent({ data: this.data, @@ -91,7 +97,7 @@ QUnit.module('mail', { }); QUnit.test('thread window\'s input can still be focused when the UI is blocked', function (assert) { - assert.expect(2); + assert.expect(2 + this.BEFORE_EACH_ASSERTIONS_NUM); var parent = this.createParent({ data: this.data, @@ -124,7 +130,7 @@ QUnit.module('mail', { }); QUnit.test('emoji popover should open correctly in thread windows', function (assert) { - assert.expect(1); + assert.expect(1 + this.BEFORE_EACH_ASSERTIONS_NUM); var parent = this.createParent({ data: this.data, @@ -147,5 +153,6 @@ QUnit.module('mail', { done(); }, 0); }); + }); }); diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml index 36899afcdb2..bda7c071a0c 100644 --- a/addons/mail/views/mail_templates.xml +++ b/addons/mail/views/mail_templates.xml @@ -24,13 +24,17 @@ - + + + + - + + + -