[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 (6448420c5d).
This commit is contained in:
Alexandre Kühn
2018-08-13 14:17:27 +02:00
parent 3de97f1689
commit f2302dfb27
47 changed files with 1422 additions and 930 deletions
-1
View File
@@ -2,7 +2,6 @@ odoo.define('bus.CrossTab', function (require) {
"use strict"; "use strict";
var Longpolling = require('bus.Longpolling'); var Longpolling = require('bus.Longpolling');
var session = require('web.session');
/** /**
+60 -35
View File
@@ -44,6 +44,12 @@ if (!_.contains(urlHistory, page)) {
var LivechatButton = Widget.extend({ var LivechatButton = Widget.extend({
className:'openerp o_livechat_button d-print-none', 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: { events: {
'click': '_openChat' 'click': '_openChat'
}, },
@@ -112,7 +118,7 @@ var LivechatButton = Widget.extend({
* @param {Object} [options={}] * @param {Object} [options={}]
*/ */
_addMessage: function (data, options) { _addMessage: function (data, options) {
options = _.extend({}, options, { options = _.extend({}, this.options, options, {
serverURL: this._serverURL, serverURL: this._serverURL,
}); });
var message = new WebsiteLivechatMessage(this, data, options); var message = new WebsiteLivechatMessage(this, data, options);
@@ -130,7 +136,7 @@ var LivechatButton = Widget.extend({
this._chatWindow.$('.o_thread_composer input').prop('disabled', true); this._chatWindow.$('.o_thread_composer input').prop('disabled', true);
var feedback = new Feedback(this, this._livechat); 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('send_message', this, this._sendMessage);
feedback.on('feedback_sent', this, this._closeChat); feedback.on('feedback_sent', this, this._closeChat);
@@ -159,7 +165,7 @@ var LivechatButton = Widget.extend({
} else { // normal message } else { // normal message
this._addMessage(notification[1]); this._addMessage(notification[1]);
this._renderMessages(); this._renderMessages();
if (this._chatWindow.isFolded() || !this._chatWindow.threadWidget.isAtBottom()) { if (this._chatWindow.isFolded() || !this._chatWindow.isAtBottom()) {
this._livechat.incrementUnreadCounter(); this._livechat.incrementUnreadCounter();
} }
} }
@@ -204,13 +210,16 @@ var LivechatButton = Widget.extend({
if (!livechatData || !livechatData.operator_pid) { if (!livechatData || !livechatData.operator_pid) {
alert(_t("None of our collaborators seems to be available, please try again later.")); alert(_t("None of our collaborators seems to be available, please try again later."));
} else { } else {
self._livechat = new WebsiteLivechat(livechatData); self._livechat = new WebsiteLivechat({
parent: self,
data: livechatData
});
self._openChatWindow(); self._openChatWindow();
self._sendWelcomeMessage(); self._sendWelcomeMessage();
self._renderMessages(); self._renderMessages();
self.busBus.addChannel(self._livechat.getUUID()); self.call('bus_service', 'addChannel', self._livechat.getUUID());
self.busBus.startPolling(); self.call('bus_service', 'startPolling');
utils.set_cookie('im_livechat_session', JSON.stringify(self._livechat.toData()), 60*60); 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); 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._chatWindow.$el.css(cssProps);
self.$el.hide(); 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 * @private
*/ */
_renderMessages: function () { _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._livechat.setMessages(this._messages);
this._chatWindow.render(); this._chatWindow.render();
if (shouldScroll) { if (shouldScroll) {
this._chatWindow.threadWidget.scrollToBottom(); this._chatWindow.scrollToBottom();
} }
}, },
/** /**
@@ -282,7 +266,7 @@ var LivechatButton = Widget.extend({
return session return session
.rpc('/mail/chat_post', {uuid: this._livechat.getUUID(), message_content: message.content}) .rpc('/mail/chat_post', {uuid: this._livechat.getUUID(), message_content: message.content})
.then(function () { .then(function () {
self._chatWindow.threadWidget.scrollToBottom(); self._chatWindow.scrollToBottom();
}); });
}, },
/** /**
@@ -306,6 +290,23 @@ var LivechatButton = Widget.extend({
// Handlers // 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 * @private
* @param {Array[]} notifications * @param {Array[]} notifications
@@ -318,10 +319,34 @@ var LivechatButton = Widget.extend({
}, },
/** /**
* @private * @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); 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();
},
}); });
/* /*
@@ -3,6 +3,8 @@ odoo.define('im_livechat.model.WebsiteLivechat', function (require) {
var AbstractThread = require('mail.model.AbstractThread'); var AbstractThread = require('mail.model.AbstractThread');
var session = require('web.session');
/** /**
* Thread model that represents a livechat on the website-side. This livechat * Thread model that represents a livechat on the website-side. This livechat
* is not linked to the mail service. * is not linked to the mail service.
@@ -12,34 +14,37 @@ var WebsiteLivechat = AbstractThread.extend({
/** /**
* @override * @override
* @private * @private
* @param {Object} livechatData * @param {Object} params
* @param {boolean} [livechatData.folded] states whether the livechat is * @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 * folded or not. It is considered only if this is defined and it is a
* boolean. * boolean.
* @param {integer} livechatData.id the ID of this livechat. * @param {integer} params.data.id the ID of this livechat.
* @param {integer} [livechatData.message_unread_counter] the * @param {integer} [params.data.message_unread_counter] the unread counter
* unread counter of this livechat. * of this livechat.
* @param {Array} livechatData.operator_pid * @param {Array} params.data.operator_pid
* @param {string} livechatData.name the name of this livechat. * @param {string} params.data.name the name of this livechat.
* @param {string} [livechatData.state] if 'folded', the livechat is folded. * @param {string} [params.data.state] if 'folded', the livechat is folded.
* This is ignored if `folded` is provided and is a boolean value. * 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) { init: function (params) {
var params = { data: livechatData }; this._super.apply(this, arguments);
this._super.call(this, params);
this._operatorPID = livechatData.operator_pid; this._members = [];
this._uuid = livechatData.uuid; this._operatorPID = params.data.operator_pid;
this._uuid = params.data.uuid;
if (livechatData.message_unread_counter !== undefined) { if (params.data.message_unread_counter !== undefined) {
this._unreadCounter = livechatData.message_unread_counter; this._unreadCounter = params.data.message_unread_counter;
} }
if (_.isBoolean(livechatData.folded)) { if (_.isBoolean(params.data.folded)) {
this._folded = livechatData.folded; this._folded = params.data.folded;
} else { } else {
this._folded = livechatData.state === 'folded'; this._folded = params.data.state === 'folded';
} }
}, },
@@ -66,6 +71,16 @@ var WebsiteLivechat = AbstractThread.extend({
getUUID: function () { getUUID: function () {
return this._uuid; 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 * AKU: hack for the moment
* *
@@ -87,6 +102,20 @@ var WebsiteLivechat = AbstractThread.extend({
uuid: this.getUUID(), 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; return WebsiteLivechat;
@@ -13,7 +13,7 @@ var AbstractMessage = require('mail.model.AbstractMessage');
var WebsiteLivechatMessage = AbstractMessage.extend({ var WebsiteLivechatMessage = AbstractMessage.extend({
/** /**
* @param {Widget} parent * @param {im_livechat.im_livechat.LivechatButton} parent
* @param {Object} data * @param {Object} data
* @param {Object} options * @param {Object} options
* @param {string} options.serverURL * @param {string} options.serverURL
@@ -11,7 +11,7 @@ var AbstractThreadWindow = require('mail.AbstractThreadWindow');
var LivechatWindow = AbstractThreadWindow.extend({ var LivechatWindow = AbstractThreadWindow.extend({
/** /**
* @override * @override
* @param parent * @param {im_livechat.im_livechat.LivechatButton} parent
* @param {im_livechat.model.WebsiteLivechat} thread * @param {im_livechat.model.WebsiteLivechat} thread
*/ */
init: function (parent, thread) { init: function (parent, thread) {
@@ -28,7 +28,15 @@ var LivechatWindow = AbstractThreadWindow.extend({
* @override * @override
*/ */
close: function () { 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) * Warn the parent widget (LivechatButton)
@@ -38,7 +46,7 @@ var LivechatWindow = AbstractThreadWindow.extend({
*/ */
toggleFold: function () { toggleFold: function () {
this._super.apply(this, arguments); this._super.apply(this, arguments);
this.trigger('save_chat'); this.trigger_up('save_chat_window');
this.updateVisualFoldState(); this.updateVisualFoldState();
}, },
@@ -52,7 +60,8 @@ var LivechatWindow = AbstractThreadWindow.extend({
* @param {Object} messageData * @param {Object} messageData
*/ */
_postMessage: function (messageData) { _postMessage: function (messageData) {
this.trigger('post_message', messageData); this.trigger_up('post_message_chat_window', { messageData: messageData });
this._super.apply(this, arguments);
}, },
}); });
@@ -186,7 +186,10 @@ MailManager.include({
* @override * @override
*/ */
_makeMessage: function (data) { _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 new SupportMessage(this, data, this._emojis);
} }
return this._super.apply(this, arguments); return this._super.apply(this, arguments);
@@ -239,10 +242,10 @@ MailManager.include({
// ignore history requests // ignore history requests
return; return;
} }
var message = _.extend(notification[1], { var messageData = _.extend(notification[1], {
channel_ids: [self.supportChannelUUID], channel_ids: [self.supportChannelUUID],
}); });
self._handleChannelNotification(message); self._handleChannelNotification({ data: messageData });
}); });
}, },
}); });
@@ -3,7 +3,7 @@ odoo.define('im_support.SupportChannel', function (require) {
var supportSession = require('im_support.SupportSession'); var supportSession = require('im_support.SupportSession');
var ThreadWithCache = require('mail.model.ThreadWithCache'); var SearchableThread = require('mail.model.SearchableThread');
var core = require('web.core'); var core = require('web.core');
var session = require('web.session'); var session = require('web.session');
@@ -17,9 +17,9 @@ var _t = core._t;
* communicate from their respective 'backend' access. * communicate from their respective 'backend' access.
* *
* FIXME: it should inherit from mail.model.Channel, not from * 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 * @override
@@ -51,7 +51,7 @@ var SupportChannel = ThreadWithCache.extend({
this._super.apply(this, arguments); this._super.apply(this, arguments);
// force stuff that should probably be in Thread (or at least // 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._detached = data.is_minimized;
this._folded = data.state === 'folded'; this._folded = data.state === 'folded';
this._uuid = data.uuid; this._uuid = data.uuid;
@@ -100,6 +100,22 @@ var SupportChannel = ThreadWithCache.extend({
var value = folded ? 'folded' : 'open'; var value = folded ? 'folded' : 'open';
this.call('mail_service', 'updateSupportChannelState', value); 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 * FIXME: this method is necessary just because the support channel is
* considered as a channel, even though it does not inherit from * considered as a channel, even though it does not inherit from
@@ -128,44 +144,31 @@ var SupportChannel = ThreadWithCache.extend({
return true; return true;
}, },
/** /**
* Support channels are not considered as chat, so they are not displayed * Called when fold or detach (or both) status have changed on the support
* in the 'chat' filter of the systray messaging menu. * channel.
* *
* @override * Note: this is partially a hack due to support channel not being a
* @returns {boolean} * channel. Also, the support channel uses another way to handle its window
*/ * state, by means of the local storage.
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.
* *
* @returns {integer} * @param {Object} params
* @param {boolean} [params.folded]
* @param {boolean} [params.detached]
*/ */
getNeedactionCounter: function () { updateWindowState: function (params) {
return 0; if ('detached' in params) {
}, this._detached = params.detached;
/** }
* @return {string} uuid of this channel if ('folded' in params) {
*/ this._folded = params.folded;
getUUID: function () { }
return this._uuid;
}, if (!this._detached) {
/** this.call('mail_service', 'updateSupportChannelState', 'closed');
* Posts the message on the Support server. } else {
* var value = this._folded ? 'folded' : 'open';
* @override this.call('mail_service', 'updateSupportChannelState', value);
* @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,
});
}, },
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
@@ -241,6 +244,21 @@ var SupportChannel = ThreadWithCache.extend({
return channelCache.messages; 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; return SupportChannel;
@@ -315,7 +315,7 @@ QUnit.test('receive messages in the Support channel', function (assert) {
body: 'A message', body: 'A message',
channel_ids: [supportChannelID], channel_ids: [supportChannelID],
}; };
var notification = [[false, 'mail.channel'], data]; var notification = [[false, 'mail.channel', 1], data];
messagingMenu.call('support_bus_service', 'trigger', 'notification', [notification]); messagingMenu.call('support_bus_service', 'trigger', 'notification', [notification]);
assert.strictEqual($('.o_thread_window .o_thread_message').length, 1, assert.strictEqual($('.o_thread_window .o_thread_message').length, 1,
+5 -6
View File
@@ -117,7 +117,7 @@ var AbstractActivityField = AbstractField.extend({
* @param {integer} params.activityID * @param {integer} params.activityID
* @param {string} params.feedback * @param {string} params.feedback
*/ */
_markActivityDone: function(params) { _markActivityDone: function (params) {
var activityID = params.activityID; var activityID = params.activityID;
var feedback = params.feedback; var feedback = params.feedback;
@@ -134,7 +134,7 @@ var AbstractActivityField = AbstractField.extend({
* @param {integer} params.activityID * @param {integer} params.activityID
* @param {string} params.feedback * @param {string} params.feedback
*/ */
_markActivityDoneAndScheduleNext: function(params) { _markActivityDoneAndScheduleNext: function (params) {
var activityID = params.activityID; var activityID = params.activityID;
var feedback = params.feedback; var feedback = params.feedback;
var self = this; var self = this;
@@ -145,8 +145,7 @@ var AbstractActivityField = AbstractField.extend({
kwargs: {feedback: feedback}, kwargs: {feedback: feedback},
context: this.record.getContext(), context: this.record.getContext(),
}).then( }).then(
function(rslt_action) { function (rslt_action) {
console.log(rslt_action);
self.do_action(rslt_action, { self.do_action(rslt_action, {
on_close: function () { on_close: function () {
self.trigger_up('reload'); self.trigger_up('reload');
@@ -352,7 +351,7 @@ var Activity = AbstractActivityField.extend({
force_email: true, force_email: true,
}, },
}; };
return this.do_action(action, { on_close: function(){}}); return this.do_action(action, { on_close: function () {} });
}, },
/** /**
* @private * @private
@@ -650,7 +649,7 @@ var KanbanActivity = AbstractActivityField.extend({
force_email: true, force_email: true,
}, },
}; };
return this.do_action(action, { on_close: function(){}}); return this.do_action(action, { on_close: function () {} });
}, },
/** /**
* @private * @private
+3 -3
View File
@@ -39,9 +39,9 @@ var Chatter = Widget.extend({
* @param {widget} parent * @param {widget} parent
* @param {Object} record * @param {Object} record
* @param {Object} mailFields * @param {Object} mailFields
* @param {boolean} [mailFields.mail_activity] * @param {string} [mailFields.mail_activity]
* @param {boolean} [mailFields.mail_followers] * @param {string} [mailFields.mail_followers]
* @param {boolean} [mailFields.mail_thread] * @param {string} [mailFields.mail_thread]
*/ */
init: function (parent, record, mailFields, options) { init: function (parent, record, mailFields, options) {
this._super.apply(this, arguments); this._super.apply(this, arguments);
+29 -29
View File
@@ -195,6 +195,11 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
/** /**
* @override * @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) { init: function (parent, action, options) {
this._super.apply(this, arguments); this._super.apply(this, arguments);
@@ -445,15 +450,12 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
!this._thread.isMassMailing(), !this._thread.isMassMailing(),
displayEmptyThread: !this._thread.hasMessages() && !this.domain.length, displayEmptyThread: !this._thread.hasMessages() && !this.domain.length,
displayNoMatchFound: !this._thread.hasMessages() && this.domain.length, displayNoMatchFound: !this._thread.hasMessages() && this.domain.length,
displaySubjectOnMessages: displaySubjectOnMessages: this._thread.isMassMailing() ||
(
this._thread.getType() !== 'mailbox' &&
this._thread.isMassMailing()
) ||
this._thread.getID() === 'mailbox_inbox' || this._thread.getID() === 'mailbox_inbox' ||
this._thread.getID() === 'mailbox_moderation', this._thread.getID() === 'mailbox_moderation',
displayEmailIcons: false, displayEmailIcons: false,
displayReplyIcons: true, displayReplyIcons: true,
displayBottomThreadFreeSpace: true,
}; };
}, },
/** /**
@@ -495,8 +497,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
(this._threadWidget.el.clientHeight === this._threadWidget.el.scrollHeight) && (this._threadWidget.el.clientHeight === this._threadWidget.el.scrollHeight) &&
!this._thread.isAllHistoryLoaded(this.domain); !this._thread.isAllHistoryLoaded(this.domain);
if (loadMoreMessages) { if (loadMoreMessages) {
return this._loadMoreMessages() return this._loadMoreMessages().then(this._loadEnoughMessages.bind(this));
.then(this._loadEnoughMessages.bind(this));
} }
}, },
/** /**
@@ -520,8 +521,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
self._thread, self._thread,
self._getThreadRenderingOptions() self._getThreadRenderingOptions()
); );
offset += dom.getPosition(document.querySelector(oldestMessageSelector) offset += dom.getPosition(document.querySelector(oldestMessageSelector)).top;
).top;
self._threadWidget.scrollToPosition(offset); self._threadWidget.scrollToPosition(offset);
}); });
}, },
@@ -558,7 +558,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
* *
* @private * @private
* @param {JQuery} $input the input to prepare * @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) * 'private' channel)
*/ */
_prepareAddThreadInput: function ($input, type) { _prepareAddThreadInput: function ($input, type) {
@@ -569,11 +569,11 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
self._lastSearchVal = _.escape(request.term); self._lastSearchVal = _.escape(request.term);
self._searchChannel(self._lastSearchVal).done(function (result){ self._searchChannel(self._lastSearchVal).done(function (result){
result.push({ result.push({
'label': _.str.sprintf( label: _.str.sprintf(
'<strong>' + _t("Create %s") + '</strong>', '<strong>' + _t("Create %s") + '</strong>',
'<em>"#' + self._lastSearchVal + '"</em>' '<em>"#' + self._lastSearchVal + '"</em>'
), ),
'value': '_create', value: '_create',
}); });
response(result); response(result);
}); });
@@ -599,7 +599,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
self.call('mail_service', 'createChannel', name, 'private'); self.call('mail_service', 'createChannel', name, 'private');
} }
}); });
} else if (type === 'dm') { } else if (type === 'dm_chat') {
$input.autocomplete({ $input.autocomplete({
source: function (request, response) { source: function (request, response) {
self._lastSearchVal = _.escape(request.term); self._lastSearchVal = _.escape(request.term);
@@ -607,11 +607,11 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
}, },
select: function (ev, ui) { select: function (ev, ui) {
var partnerID = ui.item.id; var partnerID = ui.item.id;
var dm = self.call('mail_service', 'getDmFromPartnerID', partnerID); var dmChat = self.call('mail_service', 'getDMChatFromPartnerID', partnerID);
if (dm) { if (dmChat) {
self._setThread(dm.getID()); self._setThread(dmChat.getID());
} else { } else {
self.call('mail_service', 'createChannel', partnerID, 'dm'); self.call('mail_service', 'createChannel', partnerID, 'dm_chat');
} }
// clear the input // clear the input
$(this).val(''); $(this).val('');
@@ -693,7 +693,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
needactionCounter: inbox.getMailboxCounter(), needactionCounter: inbox.getMailboxCounter(),
starredCounter: starred.getMailboxCounter(), starredCounter: starred.getMailboxCounter(),
moderationCounter: moderation ? moderation.getMailboxCounter() : 0, moderationCounter: moderation ? moderation.getMailboxCounter() : 0,
isModerator: this.call('mail_service', 'isModerator'), isMyselfModerator: this.call('mail_service', 'isMyselfModerator'),
})); }));
return $sidebar; return $sidebar;
}, },
@@ -919,7 +919,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
*/ */
_unselectMessage: function () { _unselectMessage: function () {
this._basicComposer.do_toggle(this._thread.getType() !== 'mailbox' && !this._thread.isMassMailing()); 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) { if (!config.device.isMobile) {
var composer = this._thread.getType() !== 'mailbox' && this._thread.isMassMailing() ? 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.getID() === 'mailbox_moderation' ||
( (
this._thread.isModerated() && this._thread.isModerated() &&
this._thread.isModerator() this._thread.isMyselfModerator()
) )
) { ) {
this._updateModerationButtons(); this._updateModerationButtons();
@@ -985,7 +985,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
* @param {mail.model.Thread} thread * @param {mail.model.Thread} thread
*/ */
_updateControlPanelButtons: function (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 this.$buttons
.find('.o_mail_discuss_button_invite, .o_mail_discuss_button_settings') .find('.o_mail_discuss_button_invite, .o_mail_discuss_button_settings')
.toggle(thread.getType() !== 'dm' && thread.getType() !== 'mailbox'); .toggle(thread.getType() !== 'dm' && thread.getType() !== 'mailbox');
@@ -999,11 +999,11 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
.removeClass('o_hidden'); .removeClass('o_hidden');
this.$buttons this.$buttons
.find('.o_mail_discuss_button_select_all') .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'); .removeClass('o_hidden');
this.$buttons this.$buttons
.find('.o_mail_discuss_button_unselect_all') .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'); .removeClass('o_hidden');
this.$buttons.find('.o_mail_discuss_button_moderate_all').hide(); 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()); 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'); var $input = self.$('.o_mail_add_thread[data-type=' + type + '] input');
self._prepareAddThreadInput($input, type); self._prepareAddThreadInput($input, type);
}); });
@@ -1153,16 +1153,16 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
/** /**
* Called when someone asks discuss whether the bottom of `thread` is * 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 * visible or not. An object `query` is provided in order to reponse on the
* key `isDisplayed`. * key `isVisible`.
* *
* @private * @private
* @param {mail.model.Thread} thread * @param {mail.model.Thread} thread
* @param {Object} query * @param {Object} query
* @param {boolean} query.isBottomVisible the response to provide on whether * @param {boolean} query.isVisible the response to provide on whether the
* the thread is visible in discuss. * bottom of the thread is visible in discuss.
*/ */
_onIsThreadBottomVisible: function (thread, query) { _onIsThreadBottomVisible: function (thread, query) {
query.isBottomVisible = query.isBottomVisible || query.isVisible = query.isVisible ||
( (
thread.getID() === this._thread.getID() && thread.getID() === this._thread.getID() &&
this._threadWidget.isAtBottom() this._threadWidget.isAtBottom()
@@ -1389,7 +1389,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
ev.stopPropagation(); ev.stopPropagation();
var channelID = $(ev.target).data('thread-id'); var channelID = $(ev.target).data('thread-id');
var channel = this.call('mail_service', 'getChannel', channelID); var channel = this.call('mail_service', 'getChannel', channelID);
if (channel.isAdministrator()) { if (channel.isMyselfAdministrator()) {
this._askConfirmationAdminUnsubscribe(channel); this._askConfirmationAdminUnsubscribe(channel);
} else { } else {
channel.unsubscribe(); channel.unsubscribe();
+3 -2
View File
@@ -73,7 +73,7 @@ Discuss.include({
_renderButtons: function () { _renderButtons: function () {
var self = this; var self = this;
this._super.apply(this, arguments); 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; var selector = '.o_mail_discuss_button_' + type;
self.$buttons.on('click', selector, self._onAddThread.bind(self)); self.$buttons.on('click', selector, self._onAddThread.bind(self));
}); });
@@ -106,6 +106,7 @@ Discuss.include({
*/ */
_setThread: function (threadID) { _setThread: function (threadID) {
var thread = this.call('mail_service', 'getThread', threadID); var thread = this.call('mail_service', 'getThread', threadID);
this._thread = thread;
if (thread.getType() !== 'mailbox') { if (thread.getType() !== 'mailbox') {
this.call('mail_service', 'openThreadWindow', threadID); this.call('mail_service', 'openThreadWindow', threadID);
return $.when(); return $.when();
@@ -148,7 +149,7 @@ Discuss.include({
* *
* @private * @private
* @param {string} type the thread's type to display (e.g. 'mailbox_inbox', * @param {string} type the thread's type to display (e.g. 'mailbox_inbox',
* 'mailbox_starred', 'dm'...). * 'mailbox_starred', 'dm_chat'...).
*/ */
_updateContent: function (type) { _updateContent: function (type) {
var self = this; var self = this;
@@ -344,7 +344,7 @@ var AbstractMessage = Class.extend({
* @return {boolean} * @return {boolean}
*/ */
shouldRedirectToAuthor: function () { shouldRedirectToAuthor: function () {
return !this._isAuthor(); return !this._isMyselfAuthor();
}, },
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
@@ -370,7 +370,7 @@ var AbstractMessage = Class.extend({
* @private * @private
* @return {boolean} * @return {boolean}
*/ */
_isAuthor: function () { _isMyselfAuthor: function () {
return this.hasAuthor() && (this.getAuthorID() === session.partner_id); return this.hasAuthor() && (this.getAuthorID() === session.partner_id);
}, },
/** /**
@@ -357,14 +357,6 @@ var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin
hasTrackingValues: function () { hasTrackingValues: function () {
return !!(this._trackingValueIDs && (this._trackingValueIDs.length > 0)); 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) * 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 () { isLinkedToDocumentThread: function () {
return !!(this._documentModel !== 'mail.channel' && this._documentID); 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. * States whether the current message needs moderation in general.
* *
@@ -2,6 +2,7 @@ odoo.define('mail.model.AbstractThread', function (require) {
"use strict"; "use strict";
var Class = require('web.Class'); var Class = require('web.Class');
var Mixins = require('web.mixins');
/** /**
* Abstract thread is the super class of all threads, either backend threads * 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 * Abstract threads contain abstract messages
*/ */
var AbstractThread = Class.extend({ var AbstractThread = Class.extend(Mixins.EventDispatcherMixin, {
/** /**
* @param {Object} params * @param {Object} params
* @param {Object} params.data * @param {Object} params.data
* @param {integer|string} params.data.id the ID of this thread * @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.name the name of this thread
* @param {string} params.data.status the status 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) { init: function (params) {
Mixins.EventDispatcherMixin.init.call(this, arguments);
this.setParent(params.parent);
this._folded = false; // threads are unfolded by default this._folded = false; // threads are unfolded by default
this._id = params.data.id; this._id = params.data.id;
this._name = params.data.name; this._name = params.data.name;
@@ -29,6 +35,15 @@ var AbstractThread = Class.extend({
// Public // 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 * Updates the folded state of the thread
* *
@@ -87,12 +102,6 @@ var AbstractThread = Class.extend({
hasMessages: function () { hasMessages: function () {
return !_.isEmpty(this.getMessages()); 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. * States whether this thread is folded or not.
* *
@@ -101,12 +110,77 @@ var AbstractThread = Class.extend({
isFolded: function () { isFolded: function () {
return this._folded; 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. * Resets the unread counter of this thread to 0.
*/ */
resetUnreadCounter: function () { resetUnreadCounter: function () {
this._unreadCounter = 0; 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; return AbstractThread;
@@ -1,7 +1,7 @@
odoo.define('mail.model.Channel', function (require) { odoo.define('mail.model.Channel', function (require) {
"use strict"; "use strict";
var ThreadWithCache = require('mail.model.ThreadWithCache'); var SearchableThread = require('mail.model.SearchableThread');
var mailUtils = require('mail.utils'); var mailUtils = require('mail.utils');
var session = require('web.session'); 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 * Any piece of code in JS that make use of channels must ideally interact with
* such objects, instead of direct data from the server. * such objects, instead of direct data from the server.
*/ */
var Channel = ThreadWithCache.extend({ var Channel = SearchableThread.extend({
/** /**
* @override * @override
* @param {Object} params * @param {Object} params
* @param {Object} params.data * @param {Object} params.data
* @param {string} [params.data.anonymous_name]
* @param {string} params.data.channel_type * @param {string} params.data.channel_type
* @param {integer} [params.data.create_uid] the ID of the user that has * @param {integer} [params.data.create_uid] the ID of the user that has
* created the channel. * created the channel.
@@ -29,9 +28,7 @@ var Channel = ThreadWithCache.extend({
* @param {boolean} params.data.is_moderator whether the current user is * @param {boolean} params.data.is_moderator whether the current user is
* moderator of this channel. * moderator of this channel.
* @param {string} [params.data.last_message_date] date in server-format * @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 {integer} [params.data.message_unread_counter]
* @param {string} [params.data.public] either 'public' or 'private'
* @param {string} params.data.state * @param {string} params.data.state
* @param {string} [params.data.uuid] * @param {string} [params.data.uuid]
* @param {Object} params.options * @param {Object} params.options
@@ -48,9 +45,8 @@ var Channel = ThreadWithCache.extend({
// If set, autoswitch channel on joining this channel in discuss // If set, autoswitch channel on joining this channel in discuss
// the default behaviour is to autoswitch on join. // 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._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._commands = undefined;
this._creatorUID = data.create_uid; this._creatorUID = data.create_uid;
this._detached = data.is_minimized; this._detached = data.is_minimized;
@@ -58,12 +54,12 @@ var Channel = ThreadWithCache.extend({
this._folded = data.state === 'folded'; this._folded = data.state === 'folded';
// if set: hide 'Leave channel' button // if set: hide 'Leave channel' button
this._groupBasedSubscription = data.group_based_subscription; 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._lastMessageDate = undefined;
this._massMailing = data.mass_mailing; this._members = [];
// Deferred that is resolved on fetched members of this channel. // Deferred that is resolved on fetched members of this channel.
this._membersDef = undefined; this._membersDef = undefined;
this._moderation = data.is_moderation;
// number of messages that are 'needaction', which is equivalent to the // number of messages that are 'needaction', which is equivalent to the
// number of messages in this channel that are in inbox. // number of messages in this channel that are in inbox.
this._needactionCounter = data.message_needaction_counter || 0; this._needactionCounter = data.message_needaction_counter || 0;
@@ -78,16 +74,9 @@ var Channel = ThreadWithCache.extend({
return !command.channel_types || return !command.channel_types ||
_.contains(command.channel_types, self._serverType); _.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) { if (data.last_message_date) {
this._lastMessageDate = moment(time.str_to_datetime(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) { if (data.message_unread_counter) {
this._unreadCounter = data.message_unread_counter; this._unreadCounter = data.message_unread_counter;
} }
@@ -190,6 +179,7 @@ var Channel = ThreadWithCache.extend({
* @returns {$.Promise<Object[]>} resolved with list of channel listeners * @returns {$.Promise<Object[]>} resolved with list of channel listeners
*/ */
getMentionPartnerSuggestions: function () { getMentionPartnerSuggestions: function () {
var self = this;
if (!this._membersDef) { if (!this._membersDef) {
this._membersDef = this._rpc({ this._membersDef = this._rpc({
model: 'mail.channel', model: 'mail.channel',
@@ -199,6 +189,7 @@ var Channel = ThreadWithCache.extend({
shadow: true shadow: true
}) })
.then(function (members) { .then(function (members) {
self._members = members;
return members; return members;
}); });
} }
@@ -215,7 +206,7 @@ var Channel = ThreadWithCache.extend({
*/ */
getPreview: function () { getPreview: function () {
var result = this._super.apply(this, arguments); var result = this._super.apply(this, arguments);
if (!this.isChat()) { if (!this.isTwoUserThread()) {
result.imageSRC = '/web/image/mail.channel/' + this.getID() + '/image_small'; result.imageSRC = '/web/image/mail.channel/' + this.getID() + '/image_small';
} }
var lastMessage = this.getLastMessage(); var lastMessage = this.getLastMessage();
@@ -223,7 +214,7 @@ var Channel = ThreadWithCache.extend({
author: lastMessage ? lastMessage.getDisplayedAuthor() : '', author: lastMessage ? lastMessage.getDisplayedAuthor() : '',
body: lastMessage ? mailUtils.parseAndTransform(lastMessage.getBody(), mailUtils.inline) : '', body: lastMessage ? mailUtils.parseAndTransform(lastMessage.getBody(), mailUtils.inline) : '',
date: lastMessage ? lastMessage.getDate() : moment(), 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 () { incrementNeedactionCounter: function () {
this._needactionCounter++; 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 * States whether the channel should be auto-selected on creation
* *
@@ -281,18 +263,6 @@ var Channel = ThreadWithCache.extend({
isChannel: function () { isChannel: function () {
return true; 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 * States whether the channel auto-subscribes some users in a group
* *
@@ -308,81 +278,32 @@ var Channel = ThreadWithCache.extend({
* @returns {boolean} * @returns {boolean}
*/ */
isModerated: function () { 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. * States whether the current user is moderator of this channel.
* *
* @returns {boolean} * @returns {boolean}
*/ */
isModerator: function () { isMyselfModerator: function () {
return this._isModerator; return this._isMyselfModerator;
},
/**
* Marks this channel as read.
* The last seen message will be the last message.
* Resolved with the last seen message, only for non-mailbox channels
*
* @override
* @returns {$.Promise<integer|undefined>} resolved with last message ID
* seen in the channel, and when the channel has been marked as seen on
* the server.
*/
markAsRead: function () {
if (this._unreadCounter > 0) {
this.resetUnreadCounter();
return this._throttleFetchSeen();
}
return this._super.apply(this, arguments);
},
/**
* Prepare and send a message to the server on this channel
*
* @override
* @param {Object} data data related to the new message
* @returns {$.Promise<Object>} resolved when the message has been sent to
* the server, with the object message that has been sent to the server.
*/
postMessage: function (data) {
var self = this;
return this._super.apply(this, arguments).then(function (messageData) {
_.extend(messageData, {
message_type: 'comment',
subtype: 'mail.mt_comment',
command: data.command,
});
return self._rpc({
model: 'mail.channel',
method: data.command ? 'execute_command' : 'message_post',
args: [self._id],
kwargs: messageData,
}).then(function () {
return messageData;
});
});
}, },
/** /**
* Unsubscribes from channel * Unsubscribes from channel
* *
* @abstract
* @returns {$.Promise} resolve when unsubscribed * @returns {$.Promise} resolve when unsubscribed
*/ */
unsubscribe: function () { unsubscribe: function () {},
if (_.contains(['public', 'private'], this.getType())) {
// unfollow channel
return this._rpc({
model: 'mail.channel',
method: 'action_unfollow',
args: [[this._id]],
});
} else {
// unpin livechat
return this._rpc({
model: 'mail.channel',
method: 'channel_pin',
args: [this.getUUID(), false],
});
}
},
/** /**
* Updates the internal state of the channel, and reflects the changes in * Updates the internal state of the channel, and reflects the changes in
* the UI. * the UI.
@@ -453,6 +374,47 @@ var Channel = ThreadWithCache.extend({
['need_moderation', '=', true], ['need_moderation', '=', true],
['channel_ids', 'in', [this._id]]]; ['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<Object>} resolved when the message has been sent to
* the server, with the object message that has been sent to the server.
*/
_postMessage: function (data) {
var self = this;
return this._super.apply(this, arguments).then(function (messageData) {
_.extend(messageData, {
message_type: 'comment',
subtype: 'mail.mt_comment',
command: data.command,
});
return self._rpc({
model: 'mail.channel',
method: data.command ? 'execute_command' : 'message_post',
args: [self._id],
kwargs: messageData,
}).then(function () {
return messageData;
});
});
},
}); });
return Channel; return Channel;
@@ -1,19 +1,18 @@
odoo.define('mail.model.DM', function (require) { odoo.define('mail.model.DMChat', function (require) {
"use strict"; "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 * Any piece of code in JS that make use of DMs must ideally interact with
* such objects, instead of direct data from the server. * such objects, instead of direct data from the server.
*/ */
var DM = Channel.extend({ var DMChat = TwoUserChannel.extend({
/** /**
* @override * @override
* @param {Object} params * @param {Object} params
* @param {Object} params.data * @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 {integer} params.data.direct_partner[0].id
* @param {string} params.data.direct_partner[0].im_status * @param {string} params.data.direct_partner[0].im_status
* @param {string} params.data.direct_partner[0].name * @param {string} params.data.direct_partner[0].name
@@ -23,10 +22,10 @@ var DM = Channel.extend({
var data = params.data; var data = params.data;
this._type = 'dm';
this._name = data.direct_partner[0].name;
this._directPartnerID = data.direct_partner[0].id; this._directPartnerID = data.direct_partner[0].id;
this._name = data.direct_partner[0].name;
this._status = data.direct_partner[0].im_status; 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 * 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 * user at the other end of the DM conversation. All DM chats do have a
* partner iD. * direct partner iD.
* *
* @returns {integer} * @returns {integer}
*/ */
@@ -51,36 +50,14 @@ var DM = Channel.extend({
result.imageSRC = '/web/image/res.partner/' + this.getDirectPartnerID() + '/image_small'; result.imageSRC = '/web/image/res.partner/' + this.getDirectPartnerID() + '/image_small';
return result; return result;
}, },
/**
* DM are chat
*
* @override
* @returns {boolean}
*/
isChat: function () {
return true;
},
/** /**
* @param {string} newStatus * @param {string} newStatus
*/ */
setStatus: function (newStatus) { setStatus: function (newStatus) {
this._status = 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;
}); });
@@ -57,28 +57,6 @@ var DocumentThread = Thread.extend({
// Public // 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 * Overrides to store the thread's state in the LocalStorage, so that it is
* shared between tabs, and restored on F5. * shared between tabs, and restored on F5.
@@ -178,59 +156,6 @@ var DocumentThread = Thread.extend({
isLinkedToDocument: function () { isLinkedToDocument: function () {
return true; return true;
}, },
/**
* Overrides this method so that all the messages of this document thread
* are marked as read on the server.
*
* @override
* @returns {$.Promise} resolved when messages have been marked as read on
* the server.
*/
markAsRead: function () {
this._super.apply(this, arguments);
return this.call('mail_service', 'markMessagesAsRead', this._messageIDs);
},
/**
* Post message for document thread
*
* @override
* @param {Object} data data related to the new message
* @return {$.Promise<Object>} resolved when the message has been sent to
* the server, with the object message that has been sent to the server.
*/
postMessage: function (data) {
var self = this;
var resModel = this.getDocumentModel();
var resID = this.getDocumentID();
return this._super.apply(this, arguments)
.then(function (messageData) {
_.extend(messageData, {
context: data.context,
message_type: data.message_type,
subtype: data.subtype || "mail.mt_comment",
subtype_id: data.subtype_id,
});
return self._rpc({
model: resModel,
method: 'message_post',
args: [resID],
kwargs: messageData,
})
.then(function (messageID) {
return self._rpc({
model: 'mail.message',
method: 'message_format',
args: [[messageID]],
})
.then(function (messages) {
messages[0].model = resModel;
messages[0].res_id = resID;
self.call('mail_service', 'addMessage', messages[0]);
return messages[0];
});
});
});
},
/** /**
* Set list of message IDs of this document thread * Set list of message IDs of this document thread
* *
@@ -259,6 +184,30 @@ var DocumentThread = Thread.extend({
// Private // 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 * 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<Object>} resolved when the message has been sent to
* the server, with the object message that has been sent to the server.
*/
_postMessage: function (data) {
var self = this;
var resModel = this.getDocumentModel();
var resID = this.getDocumentID();
return this._super.apply(this, arguments)
.then(function (messageData) {
_.extend(messageData, {
context: data.context,
message_type: data.message_type,
subtype: data.subtype || "mail.mt_comment",
subtype_id: data.subtype_id,
});
return self._rpc({
model: resModel,
method: 'message_post',
args: [resID],
kwargs: messageData,
})
.then(function (messageID) {
return self._rpc({
model: 'mail.message',
method: 'message_format',
args: [[messageID]],
})
.then(function (messages) {
messages[0].model = resModel;
messages[0].res_id = resID;
self.call('mail_service', 'addMessage', messages[0]);
return messages[0];
});
});
});
},
}); });
return DocumentThread; return DocumentThread;
@@ -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<Object[]>} 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;
});
@@ -1,7 +1,7 @@
odoo.define('mail.model.Mailbox', function (require) { odoo.define('mail.model.Mailbox', function (require) {
"use strict"; "use strict";
var ThreadWithCache = require('mail.model.ThreadWithCache'); var SearchableThread = require('mail.model.SearchableThread');
var core = require('web.core'); 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 * list of messages, but the inbox does not represent a conversation: Inbox is
* modeled as a mailbox. * modeled as a mailbox.
*/ */
var Mailbox = ThreadWithCache.extend({ var Mailbox = SearchableThread.extend({
/** /**
* @override * @override
@@ -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;
});
@@ -1,4 +1,4 @@
odoo.define('mail.model.ThreadWithCache', function (require) { odoo.define('mail.model.SearchableThread', function (require) {
"use strict"; "use strict";
var Thread = require('mail.model.Thread'); 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, * 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. * in order to make searches on messages in a thread.
*/ */
var ThreadWithCache = Thread.extend({ var SearchableThread = Thread.extend({
/** /**
* @override * @override
@@ -38,23 +38,6 @@ var ThreadWithCache = Thread.extend({
// Public // 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 * @override
* @param {Array} [domain] * @param {Array} [domain]
@@ -128,6 +111,24 @@ var ThreadWithCache = Thread.extend({
// Private // 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 * Gets messages from thread
* *
@@ -202,6 +203,6 @@ var ThreadWithCache = Thread.extend({
_getThreadDomain: function () {}, _getThreadDomain: function () {},
}); });
return ThreadWithCache; return SearchableThread;
}); });
@@ -5,7 +5,6 @@ var emojis = require('mail.emojis');
var AbstractThread = require('mail.model.AbstractThread'); var AbstractThread = require('mail.model.AbstractThread');
var mailUtils = require('mail.utils'); var mailUtils = require('mail.utils');
var Mixins = require('web.mixins');
var ServicesMixin = require('web.ServicesMixin'); 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. * In particular, channels and mailboxes are two different kinds of threads.
*/ */
var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, { var Thread = AbstractThread.extend(ServicesMixin, {
/** /**
* @override * @override
@@ -27,15 +26,9 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
* @param {string} [params.data.type] * @param {string} [params.data.type]
*/ */
init: function (params) { init: function (params) {
Mixins.EventDispatcherMixin.init.call(this, arguments);
this.setParent(params.parent);
this._super.apply(this, arguments); this._super.apply(this, arguments);
// threads are not detached by default // threads are not detached by default
this._detached = false; 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 // on 1st request to getPreview, fetch data if incomplete. Otherwise it
// means that there is no message in this channel. // means that there is no message in this channel.
this._previewed = false; this._previewed = false;
@@ -48,13 +41,6 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
// Public // 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 * Updates the _detached state of the thread. Must be overriden to reflect
* the new state in the interface. * the new state in the interface.
@@ -140,7 +126,7 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
return { return {
id: this.getID(), id: this.getID(),
imageSRC: '/mail/static/src/img/smiley/avatar.jpg', imageSRC: '/mail/static/src/img/smiley/avatar.jpg',
isChat: this.isChat(), isTwoUserThread: this.isTwoUserThread(),
status: this.getStatus(), status: this.getStatus(),
title: this.getName(), title: this.getName(),
unreadCounter: this.getUnreadCounter(), unreadCounter: this.getUnreadCounter(),
@@ -188,16 +174,6 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
* @abstract * @abstract
*/ */
incrementNeedactionCounter: function () {}, 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 * States whether the thread should be auto-selected on creation
* *
@@ -222,17 +198,6 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
isChannel: function () { isChannel: function () {
return false; 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. * States whether this thread is detached or not.
* A thread that is detached must have a thread window linked to itself. * 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 * This is a server-side setting, that determine the type of composer that
* is used (basic or extended composer). * is used (basic or extended composer).
* *
* By default, thread have not mass-mailings feature set.
*
* @return {boolean} * @return {boolean}
*/ */
isMassMailing: function () { isMassMailing: function () {
return this._massMailing; return false;
}, },
/** /**
@@ -277,7 +244,18 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
* *
* @returns {boolean} * @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; return false;
}, },
/** /**
@@ -294,59 +272,6 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
markAsPreviewed: function () { markAsPreviewed: function () {
this._previewed = true; 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<Object>} resolved with the message object to be sent
* to the server
*/
postMessage: function (data) {
// This message will be received from the mail composer as html content
// subtype but the urls will not be linkified. If the mail composer
// takes the responsibility to linkify the urls we end up with double
// linkification a bit everywhere. Ideally we want to keep the content
// as text internally and only make html enrichment at display time but
// the current design makes this quite hard to do.
var body = mailUtils.parseAndTransform(_.str.trim(data.content), mailUtils.addLink);
body = this._generateEmojis(body);
var messageData = {
partner_ids: data.partner_ids,
body: body,
attachment_ids: data.attachment_ids,
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 // Private
@@ -379,13 +304,58 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
return htmlString; 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<Object>} resolved with the message object to be sent
* to the server
*/
_postMessage: function (data) {
// This message will be received from the mail composer as html content
// subtype but the urls will not be linkified. If the mail composer
// takes the responsibility to linkify the urls we end up with double
// linkification a bit everywhere. Ideally we want to keep the content
// as text internally and only make html enrichment at display time but
// the current design makes this quite hard to do.
var body = mailUtils.parseAndTransform(_.str.trim(data.content), mailUtils.addLink);
body = this._generateEmojis(body);
var messageData = {
partner_ids: data.partner_ids,
body: body,
attachment_ids: data.attachment_ids,
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 * @private
*/ */
_warnUpdatedUnreadCounter: function () { _warnUpdatedUnreadCounter: function () {
this.call('mail_service', 'getMailBus') this.call('mail_service', 'getMailBus').trigger('update_thread_unread_counter', this);
.trigger('update_thread_unread_counter', this);
}, },
/** /**
* Warn other mail components that the window state of this thread has * Warn other mail components that the window state of this thread has
@@ -393,6 +363,7 @@ var Thread = AbstractThread.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
* *
* @private * @private
* @param {Object} [options={}] * @param {Object} [options={}]
* @param {boolean} [options.passively=false]
*/ */
_warnUpdatedWindowState: function (options) { _warnUpdatedWindowState: function (options) {
options = options || {}; options = options || {};
@@ -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;
});
+234 -197
View File
@@ -26,11 +26,12 @@ odoo.define('mail.Manager', function (require) {
*/ */
var AbstractService = require('web.AbstractService'); var AbstractService = require('web.AbstractService');
var Channel = require('mail.model.Channel'); var DMChat = require('mail.model.DMChat');
var DM = require('mail.model.DM'); var Livechat = require('mail.model.Livechat');
var Mailbox = require('mail.model.Mailbox'); var Mailbox = require('mail.model.Mailbox');
var MailFailure = require('mail.model.MailFailure'); var MailFailure = require('mail.model.MailFailure');
var Message = require('mail.model.Message'); var Message = require('mail.model.Message');
var MultiUserChannel = require('mail.model.MultiUserChannel');
var mailUtils = require('mail.utils'); var mailUtils = require('mail.utils');
var Bus = require('web.Bus'); var Bus = require('web.Bus');
@@ -39,7 +40,6 @@ var core = require('web.core');
var session = require('web.session'); var session = require('web.session');
var _t = core._t; var _t = core._t;
var _lt = core._lt;
var PREVIEW_MSG_MAX_SIZE = 350; // optimal for native english speakers var PREVIEW_MSG_MAX_SIZE = 350; // optimal for native english speakers
@@ -52,30 +52,10 @@ var MailManager = AbstractService.extend({
* @override * @override
*/ */
start: function () { start: function () {
this._cannedResponses = []; this._super.apply(this, arguments);
this._mailBus = new Bus(this); this._initializeInternalState();
this._commands = []; this._listenOnBuses();
this._discussMenuID = undefined; this._fetchMailStateFromServer();
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();
}, },
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
@@ -100,17 +80,7 @@ var MailManager = AbstractService.extend({
options = options || {}; options = options || {};
var message = this.getMessage(data.id); var message = this.getMessage(data.id);
if (!message) { if (!message) {
message = this._makeMessage(data); message = this._addNewMessage(data, options);
// 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);
}
} else { } else {
if (data.moderation_status === 'accepted') { if (data.moderation_status === 'accepted') {
message.setModerationStatus('accepted', { message.setModerationStatus('accepted', {
@@ -124,16 +94,17 @@ var MailManager = AbstractService.extend({
return message; 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 {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<integer>} resolved with ID of the newly created * @returns {$.Promise<integer>} resolved with ID of the newly created
* channel * channel
*/ */
createChannel: function (name, type) { createChannel: function (name, type) {
if (type === 'dm') { if (type === 'dm_chat') {
return this._createDM(name); return this._createDMChat(name);
} else { } else {
return this._createChannel(name, type); return this._createChannel(name, type);
} }
@@ -202,9 +173,9 @@ var MailManager = AbstractService.extend({
* @param {integer} partnerID * @param {integer} partnerID
* @returns {Object|undefined} channel * @returns {Object|undefined} channel
*/ */
getDmFromPartnerID: function (partnerID) { getDMChatFromPartnerID: function (partnerID) {
return _.find(this._threads, function (thread) { return _.find(this._threads, function (thread) {
return thread.getType() === 'dm' && return thread.getType() === 'dm_chat' &&
thread.getDirectPartnerID() === partnerID; thread.getDirectPartnerID() === partnerID;
}); });
}, },
@@ -314,8 +285,8 @@ var MailManager = AbstractService.extend({
* *
* @returns {boolean} * @returns {boolean}
*/ */
isModerator: function () { isMyselfModerator: function () {
return this._isModerator; return this._isMyselfModerator;
}, },
/** /**
* States whether the mail manager is ready or not * 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 * Special redirection handling for given model and id
* *
* If the model is res.partner, and there is a user associated with this * 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). * Otherwhise, open the record's form view (if not current user's).
* *
* @param {string} resModel model to open * @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 * For newly added message, postprocess threads linked to this message
* *
@@ -547,13 +540,14 @@ var MailManager = AbstractService.extend({
if (thread) { if (thread) {
if ( if (
thread.getType() !== 'mailbox' && thread.getType() !== 'mailbox' &&
!message.isAuthor() && !message.isMyselfAuthor() &&
!message.isSystemNotification() !message.isSystemNotification()
) { ) {
// HERE (remove below line for final version)
if (options.incrementUnread) { if (options.incrementUnread) {
thread.incrementUnreadCounter(); thread.incrementUnreadCounter();
} }
if (thread.isChat() && options.showNotification) { if (thread.isTwoUserThread() && options.showNotification) {
if ( if (
!self._isDiscussOpen() && !self._isDiscussOpen() &&
!config.device.isMobile && !config.device.isMobile &&
@@ -563,10 +557,10 @@ var MailManager = AbstractService.extend({
// while keeping it unread // while keeping it unread
thread.detach({ passively: true }); thread.detach({ passively: true });
} }
var query = { isBottomVisible: false }; var query = { isVisible: false };
self._mailBus.trigger('is_thread_bottom_visible', thread, query); self._mailBus.trigger('is_thread_bottom_visible', thread, query);
if (!query.isBottomVisible) { if (!self.call('bus_service', 'isOdooFocused') || !query.isVisible) {
self._notifyIncomingMessage(message, query); self._notifyIncomingMessage(message);
} }
} }
} }
@@ -598,7 +592,7 @@ var MailManager = AbstractService.extend({
* @param {string} name * @param {string} name
* @returns {$.Promise<integer>} ID of the created channel * @returns {$.Promise<integer>} ID of the created channel
*/ */
_createDM: function (name) { _createDMChat: function (name) {
var context = _.extend({ isMobile: config.device.isMobile }, session.user_context); var context = _.extend({ isMobile: config.device.isMobile }, session.user_context);
return this._rpc({ return this._rpc({
model: 'mail.channel', model: 'mail.channel',
@@ -620,6 +614,25 @@ var MailManager = AbstractService.extend({
args: [channelIDs], args: [channelIDs],
}, { shadow: true }); }, { 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 * Get previews of the channels
* *
@@ -744,9 +757,9 @@ var MailManager = AbstractService.extend({
return false; return false;
} }
if (filter === 'chat') { if (filter === 'chat') {
return thread.isChat(); return thread.isTwoUserThread();
} else if (filter === 'channels') { } else if (filter === 'channels') {
return !thread.isChat(); return !thread.isTwoUserThread();
} }
return true; return true;
}); });
@@ -809,6 +822,8 @@ var MailManager = AbstractService.extend({
* *
* @private * @private
* @param {string|undefined} [filter] * @param {string|undefined} [filter]
* @returns {$.Promise<Object[]>} resolved with list of object that are
* compatible with preview template of mail systray menu
*/ */
_getSystrayStaticPreviews: function (filter) { _getSystrayStaticPreviews: function (filter) {
var elems = []; var elems = [];
@@ -827,132 +842,29 @@ var MailManager = AbstractService.extend({
return $.when(elems); 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 * @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) { _initializeInternalState: function () {
var self = this; this._cannedResponses = [];
_.each(data.shortcodes, function (s) { this._mailBus = new Bus(this);
var cannedResponse = _.pick(s, ['id', 'source', 'substitution']); this._commands = [];
self._cannedResponses.push(cannedResponse); this._discussMenuID = undefined;
}); this._discussOpen = false;
}, this._isMyselfModerator = false;
/** this._mailFailures = [];
* Initialize the channels from the server, including public, private, DM, // list of employees for chatter mentions
* livechat, etc. this._mentionPartnerSuggestions = [];
* this._messages = [];
* @private this._moderatedChannelIDs = [];
* @param {Object} data // # of message received when odoo is out of focus
* @param {Object} [data.channel_slots] contains the data of channels to this._outOfFocusUnreadMessageCounter = 0;
* initialize, which are grouped by channel type by key of the object // partner_ids we have a pinned DM chat with
* (e.g. list of public channel data are stored in 'channel_channel') this._pinnedDmPartners = [];
* @param {Object[]} [data.channel_slots[i] list of data of channel of type // all threads, including channels, DM, mailboxes, document threads, ...
* `i` this._threads = [];
*/
_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;
}, },
/** /**
* State whether discuss app is open or not * State whether discuss app is open or not
@@ -982,26 +894,46 @@ var MailManager = AbstractService.extend({
return self._addChannel(result, options); 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. * Creates a new instance of Channel with the given data and options.
* *
* @private * @private
* @param {Object} data * @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, * @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] * @param {Object} [options]
* @returns {mail.model.Channel} * @returns {mail.model.Channel}
*/ */
_makeChannel: function (data, options) { _makeChannel: function (data, options) {
if (_.size(data.direct_partner) > 0) { if (_.size(data.direct_partner) > 0) {
return new DM({ return new DMChat({
parent: this, parent: this,
data: data, data: data,
options: options, options: options,
commands: this._commands 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, parent: this,
data: data, data: data,
options: options, options: options,
@@ -1020,16 +952,13 @@ var MailManager = AbstractService.extend({
}, },
/** /**
* shows a popup to notify a new received message. * shows a popup to notify a new received message.
* This will also rename the odoo tab browser if * This will also rename the browser tab if this is not the active tab.
* the user has no focus on it.
* *
* @private * @private
* @param {mail.model.Message} message message received * @param {mail.model.Message} message message received
* @param {Object} options
* @param {boolean} options.isDisplayed
*/ */
_notifyIncomingMessage: function (message, options) { _notifyIncomingMessage: function (message) {
if (this.call('bus_service', 'isOdooFocused') && options.isDisplayed) { if (this.call('bus_service', 'isOdooFocused')) {
// no need to notify // no need to notify
return; return;
} }
@@ -1209,7 +1138,7 @@ var MailManager = AbstractService.extend({
* Sort previews * Sort previews
* *
* 1. unread, * 1. unread,
* 2. chat, * 2. two-user thread,
* 3. date, * 3. date,
* *
* @private * @private
@@ -1219,13 +1148,13 @@ var MailManager = AbstractService.extend({
_sortPreviews: function (previews) { _sortPreviews: function (previews) {
var res = previews.sort(function (p1, p2) { var res = previews.sort(function (p1, p2) {
var unreadDiff = Math.min(1, p2.unreadCounter) - Math.min(1, p1.unreadCounter); 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) || var dateDiff = (!!p2.date - !!p1.date) ||
( (
p2.date && p2.date &&
p2.date.diff(p1.date) p2.date.diff(p1.date)
); );
return unreadDiff || isChatDiff || dateDiff; return unreadDiff || isTwoUserThreadDiff || dateDiff;
}); });
return res; return res;
}, },
@@ -1244,6 +1173,53 @@ var MailManager = AbstractService.extend({
return _.isString(name) ? name.toLowerCase() : ''; 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) * Update internal state from server data (mail/init_messaging rpc result)
* *
@@ -1253,18 +1229,79 @@ var MailManager = AbstractService.extend({
* with all the employees * with all the employees
* @param {integer} result.menu_id the menu ID of discuss app * @param {integer} result.menu_id the menu ID of discuss app
*/ */
_updateFromServer: function (result) { _updateInternalStateFromServer: function (result) {
// commands are needed for channel instantiation // commands are needed for channel instantiation
this._initializeCommands(result); this._updateCommandsFromServer(result);
this._initializeChannels(result); this._updateChannelsFromServer(result);
this._initializeModerationSettings(result); this._updateModerationSettingsFromServer(result);
this._initializeMailboxes(result); this._updateMailboxesFromServer(result);
this._initializeMailFailures(result); this._updateMailFailuresFromServer(result);
this._initializeCannedResponses(result); this._updateCannedResponsesFromServer(result);
this._mentionPartnerSuggestions = result.mention_partner_suggestions; this._mentionPartnerSuggestions = result.mention_partner_suggestions;
this._discussMenuID = result.menu_id; 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 // Handlers
@@ -11,16 +11,12 @@ var MailManager = require('mail.Manager');
var MailFailure = require('mail.model.MailFailure'); var MailFailure = require('mail.model.MailFailure');
var core = require('web.core'); var core = require('web.core');
var session = require('web.session');
var _t = core._t; var _t = core._t;
MailManager.include({ MailManager.include({
start: function () {
this._super.apply(this, arguments);
this.call('bus_service', 'onNotification', this, this._onNotification);
},
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
// Private // Private
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
@@ -51,8 +47,9 @@ MailManager.include({
* *
* @private * @private
* @param {Object} messageData * @param {Object} messageData
* @param {Array} messageData.channel_ids list of integers and strings, * @param {integer[]} messageData.channel_ids channel IDs of this message
* where strings for static channels, e.g. 'mailbox_inbox'. * (note that 'pending moderation' messages in moderated channels do not
* have the moderated channels in this array).
*/ */
_handleChannelNotification: function (messageData) { _handleChannelNotification: function (messageData) {
var self = this; var self = this;
@@ -415,29 +412,36 @@ MailManager.include({
* @param {Array} data.messages message to display on notification * @param {Array} data.messages message to display on notification
*/ */
_handlePartnerUserConnectionNotification: function (data) { _handlePartnerUserConnectionNotification: function (data) {
var self=this; var self = this;
var partner_id = data.partner_id; var partnerID = data.partner_id;
this.call('bus_service', 'sendNotification', data.title, data.message, function(){ this.call('bus_service', 'sendNotification', data.title, data.message, function ( ){
self.call('mail_service', 'openDmWindow', partner_id); self.call('mail_service', 'openDmWindow', partnerID);
}); });
}, },
/** /**
*
* On receiving an update on user status (e.g. becoming 'online', 'offline', * On receiving an update on user status (e.g. becoming 'online', 'offline',
* 'idle', etc.). * 'idle', etc.).
* *
* @private * @private
* @param {Object} data partner infos * @param {Object} data partner infos
* @param {integer} data.id * @param {integer} data.id partner ID
* @param {string} data.im_status * @param {string} data.im_status partner new 'im status' (e.g. 'online')
*/ */
_handlePresenceNotification: function (data) { _handlePresenceNotification: function (data) {
var dm = this.getDmFromPartnerID(data.id); var dmChat = this.getDMChatFromPartnerID(data.id);
if (dm) { if (dmChat) {
dm.setStatus(data.im_status); dmChat.setStatus(data.im_status);
this._mailBus.trigger('update_dm_presence', dm); 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 * Update the message notification status of message based on update_message
* *
@@ -481,17 +485,12 @@ MailManager.include({
_.each(notifs, function (notif) { _.each(notifs, function (notif) {
var model = notif[0][1]; var model = notif[0][1];
if (model === 'ir.needaction') { if (model === 'ir.needaction') {
// new message in the inbox
self._handleNeedactionNotification(notif[1]); self._handleNeedactionNotification(notif[1]);
} else if (model === 'mail.channel') { } else if (model === 'mail.channel') {
// new message in a channel
self._handleChannelNotification(notif[1]); self._handleChannelNotification(notif[1]);
} else if (model === 'res.partner') { } else if (model === 'res.partner') {
// channel joined/left, message marked as read/(un)starred,
// chat open/closed, moderation specific, etc.
self._handlePartnerNotification(notif[1]); self._handlePartnerNotification(notif[1]);
} else if (model === 'bus.presence') { } else if (model === 'bus.presence') {
// update presence of users
self._handlePresenceNotification(notif[1]); self._handlePresenceNotification(notif[1]);
} }
}); });
@@ -20,35 +20,13 @@ var THREAD_WINDOW_WIDTH = 325 + 5; // 5 pixels between windows
* windows. * windows.
*/ */
MailManager.include({ MailManager.include({
custom_events: _.extend({}, MailManager.prototype.custom_events, {
close_blank_thread_window: '_onCloseBlankThreadWindow',
}),
// tell where to append thread window // tell where to append thread window
THREAD_WINDOW_APPENDTO: 'body', 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 // Public
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
@@ -73,18 +51,20 @@ MailManager.include({
} }
}, },
/** /**
* Open a DM in a thread window. This is useful when selecting a DM in the * Open a DM chat in a thread window. This is useful when selecting a DM
* blank thread window, so that it replaces it with the DM window. * chatin the blank thread window, so that it replaces it with the DM chat
* window.
* *
* @param {integer} partnerID * @param {integer} partnerID
*/ */
openDmWindow: function (partnerID) { openDMChatWindowFromBlankThreadWindow: function (partnerID) {
var dm = this.getDmFromPartnerID(partnerID); var dmChat = this.getDMChatFromPartnerID(partnerID);
if (!dm) { if (!dmChat) {
this._openAndDetachDm(partnerID); this._openAndDetachDMChat(partnerID);
} else { } else {
this.openThreadWindow(dm.getID()); this.openThreadWindow(dmChat.getID());
} }
this._closeBlankThreadWindow();
}, },
/** /**
* Open the thread window if discuss is not opened * Open the thread window if discuss is not opened
@@ -129,29 +109,11 @@ MailManager.include({
return thread.fetchMessages(); return thread.fetchMessages();
}).then(function () { }).then(function () {
threadWindow.render(); threadWindow.render();
threadWindow.threadWidget.scrollToBottom(); threadWindow.scrollToBottom();
// setTimeout to prevent to execute handler on first if (
// scrollTo, which is asynchronous !self._areAllThreadWindowsHidden() &&
setTimeout(function () { !thread.isFolded() &&
threadWindow.threadWidget.$el.on('scroll', null, _.debounce(function () { !threadWindow.isPassive()
if (
!threadWindow.isPassive() &&
threadWindow.threadWidget.isAtBottom()
) {
thread.markAsRead();
}
}, 100));
}, 0);
if (options.passively) {
// mark first unread messages as seen when focusing the
// window, then on scroll to bottom as usual
threadWindow.$('.o_mail_thread, .o_thread_composer')
.one('click', function () {
thread.markAsRead();
});
} else if (
!self._areThreadWindowsHidden() &&
!thread.isFolded()
) { ) {
thread.markAsRead(); thread.markAsRead();
} }
@@ -170,6 +132,8 @@ MailManager.include({
* @param {integer|string} threadID * @param {integer|string} threadID
* @param {Object} options option to be applied on opening thread window, if * @param {Object} options option to be applied on opening thread window, if
* the thread is detached * the thread is detached
* @param {boolean} [options.passively=false] if set, the window will behave
* passively.
*/ */
updateThreadWindow: function (threadID, options) { updateThreadWindow: function (threadID, options) {
var thread = this.getThread(threadID); var thread = this.getThread(threadID);
@@ -198,14 +162,14 @@ MailManager.include({
this._threadWindows.splice(this._availableSlotsForThreadWindows-1, 0, threadWindow); 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. * When discuss is open, the thread windows are hidden.
* *
* @private * @private
* @returns {boolean} * @returns {boolean}
*/ */
_areThreadWindowsHidden: function () { _areAllThreadWindowsHidden: function () {
return this._isDiscussOpen(); return $(this.THREAD_WINDOW_APPENDTO).hasClass('o_no_thread_window');
}, },
/** /**
* Close the thread window linked to the thread with ID `threadID`. * Close the thread window linked to the thread with ID `threadID`.
@@ -224,6 +188,15 @@ MailManager.include({
threadWindow.destroy(); 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 * 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 * 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 * Get the blank thread window, which is the special thread window that has
* no thread linked to it. * no thread linked to it.
* *
* This is useful in case a DM window may replace the blank thread window, * This is useful in case a DM chat window may replace the blank thread
* when we want to open a DM from the blank thread window. * window, when we want to open a DM chat from the blank thread window.
* *
* @private * @private
* @returns {mail.ThreadWindow|undefined} the "blank thread" window, * @returns {mail.ThreadWindow|undefined} the "blank thread" window,
@@ -298,6 +271,40 @@ MailManager.include({
return threadWindow.getID() === threadID; 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 * Make the hidden thread window dropdown menu, that is render it and set
* event listener on this dropdown menu DOM element. * event listener on this dropdown menu DOM element.
@@ -371,16 +378,16 @@ MailManager.include({
threadWindow.toggleFold(false); 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 * This method assumes that no such DM chat exists locally, so it is kind
* "create DM and open DM window" operation * of a "create DM chat and open DM chat window" operation
* *
* @private * @private
* @param {integer} partnerID * @param {integer} partnerID
* @returns {$.Promise<integer>} resolved with ID of the dm channel * @returns {$.Promise<integer>} resolved with ID of the DM chat
*/ */
_openAndDetachDm: function (partnerID) { _openAndDetachDMChat: function (partnerID) {
return this._rpc({ return this._rpc({
model: 'mail.channel', model: 'mail.channel',
method: 'channel_get_and_minimize', method: 'channel_get_and_minimize',
@@ -497,7 +504,7 @@ MailManager.include({
* @private * @private
*/ */
_repositionThreadWindows: function () { _repositionThreadWindows: function () {
if (this._areThreadWindowsHidden()) { if (this._areAllThreadWindowsHidden()) {
return; return;
} }
this._computeAvailableSlotsForThreadWindows(this._threadWindows.length); this._computeAvailableSlotsForThreadWindows(this._threadWindows.length);
@@ -532,28 +539,21 @@ MailManager.include({
* *
* @private * @private
* @param {mail.model.Message} message * @param {mail.model.Message} message
* @param {boolean} [scrollBottom=false] if set, thread windows with this * @param {Object} options
* message should scroll to the bottom if the message is visible * @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) { _updateThreadWindowsFromMessage: function (message, options) {
var self = this; if (this._areAllThreadWindowsHidden()) {
return;
}
_.each(this._threadWindows, function (threadWindow) { _.each(this._threadWindows, function (threadWindow) {
if (_.contains(message.getThreadIDs(), threadWindow.getID())) { if (_.contains(message.getThreadIDs(), threadWindow.getID())) {
var thread = self.getThread(threadWindow.getID()); threadWindow.update(options);
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();
}
});
} }
}); });
}, },
@@ -562,6 +562,14 @@ MailManager.include({
// Handlers // Handlers
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
/**
* Called when manually closing the blank thread window.
*
* @private
*/
_onCloseBlankThreadWindow: function () {
this._closeBlankThreadWindow();
},
/** /**
* @private * @private
* @param {boolean} open * @param {boolean} open
@@ -579,21 +587,21 @@ MailManager.include({
/** /**
* Called when someone asks window manager whether the bottom of `thread` is * 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 * visible or not. An object `query` is provided in order to reponse on the
* key `isDisplayed`. * key `isVisible`.
* *
* @private * @private
* @param {mail.model.Thread} thread * @param {mail.model.Thread} thread
* @param {Object} query * @param {Object} query
* @param {boolean} query.isBottomVisible write on it * @param {boolean} query.isVisible write on it
*/ */
_onIsThreadBottomVisible: function (thread, query) { _onIsThreadBottomVisible: function (thread, query) {
_.each(this._threadWindows, function (threadWindow) { _.each(this._threadWindows, function (threadWindow) {
if ( if (
thread.getID() === threadWindow.getID() && thread.getID() === threadWindow.getID() &&
threadWindow.threadWidget.isAtBottom() && threadWindow.isAtBottom() &&
!threadWindow.isHidden() !threadWindow.isHidden()
) { ) {
query.isBottomVisible = true; query.isVisible = true;
} }
}); });
}, },
@@ -617,7 +625,7 @@ MailManager.include({
* @param {Object} message * @param {Object} message
*/ */
_onNewMessage: function (message) { _onNewMessage: function (message) {
this._updateThreadWindows(message, true); this._updateThreadWindowsFromMessage(message, { keepBottom: true, passively: true });
}, },
/** /**
* Close the thread window when unsusbscribe from a channel. * Close the thread window when unsusbscribe from a channel.
@@ -677,7 +685,7 @@ MailManager.include({
* @param {Object} message * @param {Object} message
*/ */
_onUpdateMessage: function (message) { _onUpdateMessage: function (message) {
this._updateThreadWindows(message, false); this._updateThreadWindowsFromMessage(message, { keepBottom: false });
}, },
}); });
@@ -132,9 +132,7 @@ var ActivityMenu = Widget.extend({
}); });
}, },
/** /**
* When menu clicked update activity preview if counter updated
* @private * @private
* @param {MouseEvent} event
*/ */
_onActivityMenuClick: function () { _onActivityMenuClick: function () {
if (!this._isOpen()) { if (!this._isOpen()) {
@@ -7,6 +7,8 @@ var SystrayMenu = require('web.SystrayMenu');
var Widget = require('web.Widget'); var Widget = require('web.Widget');
var QWeb = core.qweb; var QWeb = core.qweb;
var _t = core._t;
/** /**
* Menu item appended in the systray part of the navbar * 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 // we are opening the dropdown so update its content
this._updatePreviews(); this._updatePreviews();
} }
this._updatePreviews();
}, },
/** /**
* @private * @private
+1 -1
View File
@@ -37,7 +37,7 @@ var ThreadField = AbstractField.extend({
this.dp = new concurrency.DropPrevious(); this.dp = new concurrency.DropPrevious();
this._threadWidget = new ThreadWidget(this, { this._threadWidget = new ThreadWidget(this, {
displayOrder: ThreadWidget.ORDER.DESC, displayOrder: ThreadWidget.ORDER.ASC,
displayDocumentLinks: false, displayDocumentLinks: false,
displayMarkAsRead: false, displayMarkAsRead: false,
squashCloseMessages: false, squashCloseMessages: false,
+21 -17
View File
@@ -12,8 +12,8 @@ var QWeb = core.qweb;
var _t = core._t; var _t = core._t;
var ORDER = { var ORDER = {
ASC: 1, ASC: 1, // visually, chronological order of thread from bottom to top
DESC: -1, DESC: -1, // visually, chronological order of thread from top to bottom
}; };
var READ_MORE = _t("read more"); 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, // options when the thread is enabled (e.g. can send message,
// interact on messages, etc.) // interact on messages, etc.)
this._enabledOptions = _.defaults(options || {}, { this._enabledOptions = _.defaults(options || {}, {
displayOrder: ORDER.ASC, displayOrder: ORDER.DESC,
displayMarkAsRead: true, displayMarkAsRead: true,
displayStars: true, displayStars: true,
displayDocumentLinks: true, displayDocumentLinks: true,
@@ -78,20 +78,16 @@ var ThreadWidget = Widget.extend({
loadMoreOnScroll: this._enabledOptions.loadMoreOnScroll, loadMoreOnScroll: this._enabledOptions.loadMoreOnScroll,
}; };
this._selectedMessageID = null; this._selectedMessageID = null;
this._currentThreadID = null;
}, },
/** /**
* @override * @override
*/ */
destroy: function () { destroy: function () {
clearInterval(this._updateTimestampsInterval); clearInterval(this._updateTimestampsInterval);
}, },
/** /**
* @param {mail.model.AbstractMessage[]} messages list of messages of the * @param {mail.model.AbstractThread} thread the thread to render.
* thread, ordered by increasing IDs (a higher ID means a more recent
* message).
* @param {}
* @param {Object} [options] * @param {Object} [options]
* @param {integer} [options.displayOrder=ORDER.ASC] order of displaying * @param {integer} [options.displayOrder=ORDER.ASC] order of displaying
* messages in the thread: * messages in the thread:
@@ -99,11 +95,18 @@ var ThreadWidget = Widget.extend({
* - ORDER.DESC: last message is at the top of the thread * - ORDER.DESC: last message is at the top of the thread
* @param {boolean} [options.displayLoadMore] * @param {boolean} [options.displayLoadMore]
* @param {boolean} [options.isCreateMode] * @param {boolean} [options.isCreateMode]
* @param {boolean} [options.scrollToBottom=false]
* @param {boolean} [options.squashCloseMessages] * @param {boolean} [options.squashCloseMessages]
*/ */
render: function (thread, options) { render: function (thread, options) {
var self = this; 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 // copy so that reverse do not alter order in the thread object
var messages = _.clone(thread.getMessages()); var messages = _.clone(thread.getMessages());
@@ -115,9 +118,6 @@ var ThreadWidget = Widget.extend({
return message.getAttachments(); return message.getAttachments();
}))); })));
if (modeOptions.displayOrder === ORDER.DESC) {
messages.reverse();
}
options = _.extend({}, modeOptions, options, { options = _.extend({}, modeOptions, options, {
selectedMessageID: this._selectedMessageID, selectedMessageID: this._selectedMessageID,
}); });
@@ -160,6 +160,11 @@ var ThreadWidget = Widget.extend({
} }
prevMessage = message; prevMessage = message;
}); });
if (modeOptions.displayOrder === ORDER.DESC) {
messages.reverse();
}
this.$el.html(QWeb.render('mail.widget.Thread', { this.$el.html(QWeb.render('mail.widget.Thread', {
thread: thread, thread: thread,
displayAuthorMessages: displayAuthorMessages, displayAuthorMessages: displayAuthorMessages,
@@ -175,6 +180,10 @@ var ThreadWidget = Widget.extend({
self._insertReadMore($message); self._insertReadMore($message);
}); });
if (shouldScrollToBottomAfterRendering) {
this.scrollToBottom();
}
if (!this._updateTimestampsInterval) { if (!this._updateTimestampsInterval) {
this.updateTimestampsInterval = setInterval(function () { this.updateTimestampsInterval = setInterval(function () {
self._updateTimestamps(); self._updateTimestamps();
@@ -368,11 +377,6 @@ var ThreadWidget = Widget.extend({
}); });
}); });
}, },
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/** /**
* @private * @private
* @param {Object} options * @param {Object} options
@@ -33,10 +33,10 @@ var AbstractThreadWindow = Widget.extend({
events: { events: {
'click .o_thread_window_close': '_onClickClose', 'click .o_thread_window_close': '_onClickClose',
'click .o_thread_window_title': '_onClickFold', 'click .o_thread_window_title': '_onClickFold',
'click .o_thread_composer': '_onComposerClick', 'click .o_composer_text_field': '_onComposerClick',
'click .o_mail_thread': '_onThreadWindowClicked', 'click .o_mail_thread': '_onThreadWindowClicked',
'keydown .o_thread_composer': '_onKeydown', 'keydown .o_composer_text_field': '_onKeydown',
'keypress .o_thread_composer': '_onKeypress', 'keypress .o_composer_text_field': '_onKeypress',
}, },
/** /**
* Children of this class must make use of `thread`, which is an object that * 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 * thread window is linked to. If not set, it is the "blank" thread
* window. * window.
* @param {Object} [options={}] * @param {Object} [options={}]
* @param {mail.model.AbstractThread} [options.thread]
*/ */
init: function (parent, thread, options) { init: function (parent, thread, options) {
this._super(parent); this._super(parent);
@@ -65,16 +66,19 @@ var AbstractThreadWindow = Widget.extend({
this._hidden = false; this._hidden = false;
this._thread = thread || null; this._thread = thread || null;
this._debouncedOnScroll = _.debounce(this._onScroll.bind(this), 100);
if (!this.hasThread()) { if (!this.hasThread()) {
// internal fold state of thread window without any thread // internal fold state of thread window without any thread
this._folded = false; this._folded = false;
} }
}, },
start: function () { start: function () {
var self = this;
this.$input = this.$('.o_composer_text_field'); this.$input = this.$('.o_composer_text_field');
this.$header = this.$('.o_thread_window_header'); this.$header = this.$('.o_thread_window_header');
this.threadWidget = new ThreadWidget(this, { this._threadWidget = new ThreadWidget(this, {
displayDocumentLinks: false, displayDocumentLinks: false,
displayMarkAsRead: false, displayMarkAsRead: false,
displayStars: this.options.displayStars, displayStars: this.options.displayStars,
@@ -89,7 +93,9 @@ var AbstractThreadWindow = Widget.extend({
var margin_dir = _t.database.parameters.direction === "rtl" ? "margin-left" : "margin-right"; var margin_dir = _t.database.parameters.direction === "rtl" ? "margin-left" : "margin-right";
this.$el.css(margin_dir, $.position.scrollbarWidth()); 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); return $.when(this._super(), def);
}, },
/** /**
@@ -125,8 +131,9 @@ var AbstractThreadWindow = Widget.extend({
*/ */
close: function () {}, close: function () {},
/** /**
* Get the status of the thread, such as the im status of a DM ('online', * Get the status of the thread, such as the im status of a DM chat
* 'offline', etc.). If this window has no thread, returns `undefined`. * ('online', 'offline', etc.). If this window has no thread, returns
* `undefined`.
* *
* @returns {string|undefined} * @returns {string|undefined}
*/ */
@@ -172,6 +179,15 @@ var AbstractThreadWindow = Widget.extend({
hasThread: function () { hasThread: function () {
return !! this._thread; 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 * 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 * thread related to this window, it means this is the "blank" thread
@@ -202,13 +218,22 @@ var AbstractThreadWindow = Widget.extend({
isHidden: function () { isHidden: function () {
return this._hidden; 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 the thread window
*/ */
render: function () { render: function () {
this.renderHeader(); this.renderHeader();
if (this.hasThread()) { 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( this.$header.html(
QWeb.render('mail.AbstractThreadWindow.HeaderContent', options)); 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 * 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 * of the thread model. If the boolean parameter `folded` is provided, it
@@ -244,7 +275,7 @@ var AbstractThreadWindow = Widget.extend({
*/ */
updateVisualFoldState: function () { updateVisualFoldState: function () {
if (!this.isFolded()) { if (!this.isFolded()) {
this.threadWidget.scrollToBottom(); this._threadWidget.scrollToBottom();
this._focusInput(); this._focusInput();
} }
this._animateFold(); this._animateFold();
@@ -311,13 +342,22 @@ var AbstractThreadWindow = Widget.extend({
return this._thread.getID(); 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 * @private
* @param {Object} messageData * @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. * Update the fold state of the thread.
* This function is called when toggling the fold state of this window. * This function is called when toggling the fold state of this window.
@@ -334,6 +374,13 @@ var AbstractThreadWindow = Widget.extend({
this._folded = folded; this._folded = folded;
} }
}, },
/**
* Warn other mail components that the unread counter has been updated
*
* @abstract
* @private
*/
_warnUpdatedUnreadCounter: function () {},
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
// Handlers // Handlers
@@ -416,6 +463,14 @@ var AbstractThreadWindow = Widget.extend({
_onKeypress: function (ev) { _onKeypress: function (ev) {
ev.stopPropagation(); // to prevent jquery's blockUI to cancel event 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 * 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. * input. An exception is made when the user is selecting something.
@@ -16,7 +16,10 @@ var _t = core._t;
*/ */
var ThreadWindow = AbstractThreadWindow.extend({ var ThreadWindow = AbstractThreadWindow.extend({
template: 'mail.ThreadWindow', template: 'mail.ThreadWindow',
custom_events: AbstractThreadWindow.prototype.custom_events,
events: _.extend({}, AbstractThreadWindow.prototype.events, { events: _.extend({}, AbstractThreadWindow.prototype.events, {
'click .o_mail_thread': '_onThreadWindowFocus',
'click .o_thread_composer': '_onThreadWindowFocus',
'click .o_thread_window_expand': '_onClickExpand', 'click .o_thread_window_expand': '_onClickExpand',
}), }),
/** /**
@@ -25,7 +28,8 @@ var ThreadWindow = AbstractThreadWindow.extend({
* @override * @override
* @param {mail.Manager} parent * @param {mail.Manager} parent
* @param {mail.model.Thread} [thread = null] if not set, this is a "blank" * @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 {Object} [options={}]
* @param {boolean} [options.passively=false] * @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 // don't automatically mark unread messages as seen when at the bottom
// of the thread // of the thread
this._passive = this.options.passively; this._passive = this.options.passively || false;
if (!this.hasThread()) { if (!this.hasThread()) {
// remembered partner ID of "blank" thread window in order to be // 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; this.directPartnerID = null;
} }
}, },
@@ -54,10 +58,11 @@ var ThreadWindow = AbstractThreadWindow.extend({
var composerDef; var composerDef;
if (!this.hasThread()) { if (!this.hasThread()) {
this._startWithoutThread(); this._startWithoutThread();
} else if (this._needsComposer()) { } else if (this.needsComposer()) {
var basicComposer = new BasicComposer(this, { var basicComposer = new BasicComposer(this, {
mentionPartnersRestricted: this._thread.getType() !== 'document_thread', mentionPartnersRestricted: this._thread.getType() !== 'document_thread',
isMini: true isMini: true,
thread: this._thread,
}); });
basicComposer.on('post_message', this, this._postMessage); basicComposer.on('post_message', this, this._postMessage);
basicComposer.once('input_focused', this, function () { basicComposer.once('input_focused', this, function () {
@@ -85,6 +90,8 @@ var ThreadWindow = AbstractThreadWindow.extend({
close: function () { close: function () {
if (this.hasThread()) { if (this.hasThread()) {
this._thread.close(); this._thread.close();
} else {
this.trigger_up('close_blank_thread_window');
} }
}, },
/** /**
@@ -124,6 +131,24 @@ var ThreadWindow = AbstractThreadWindow.extend({
isPassive: function () { isPassive: function () {
return this._passive; 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 * Turn the thread window in active mode, so that when the bottom of the
* thread is visible, it is automatically marked as read. * thread is visible, it is automatically marked as read.
@@ -131,6 +156,42 @@ var ThreadWindow = AbstractThreadWindow.extend({
removePassive: function () { removePassive: function () {
this._passive = false; 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 // Private
@@ -155,31 +216,11 @@ var ThreadWindow = AbstractThreadWindow.extend({
* @private * @private
*/ */
_listenThreadWidget: function () { _listenThreadWidget: function () {
this.threadWidget this._threadWidget
.on('redirect', this, this._onRedirect) .on('redirect', this, this._onRedirect)
.on('redirect_to_channel', this, this._onRedirectToChannel) .on('redirect_to_channel', this, this._onRedirectToChannel)
.on('toggle_star_status', this, this._onToggleStarStatus); .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. * Open this thread window.
* This private method exists only for the purpose of providing a callback * This private method exists only for the purpose of providing a callback
@@ -190,24 +231,6 @@ var ThreadWindow = AbstractThreadWindow.extend({
_open: function () { _open: function () {
this.call('mail_service', 'openThreadWindow', this.getID()); 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 * @private
*/ */
@@ -222,10 +245,10 @@ var ThreadWindow = AbstractThreadWindow.extend({
}, },
select: function (event, ui) { select: function (event, ui) {
// remember partner ID so that we can replace this window // remember partner ID so that we can replace this window
// with new DM window // with new DM chat window
var partnerID = ui.item.id; var partnerID = ui.item.id;
self.directPartnerID = partnerID; self.directPartnerID = partnerID;
self.call('mail_service', 'openDmWindow', partnerID); self.call('mail_service', 'openDMChatWindowFromBlankThreadWindow', partnerID);
}, },
}) })
.focus(); .focus();
@@ -248,7 +271,7 @@ var ThreadWindow = AbstractThreadWindow.extend({
_onClickExpand: _.debounce(function (ev) { _onClickExpand: _.debounce(function (ev) {
var self = this; var self = this;
ev.preventDefault(); ev.preventDefault();
if (this._thread.getType() === 'document_thread') { if (this.hasThread() && this._thread.getType() === 'document_thread') {
this.do_action({ this.do_action({
type: 'ir.actions.act_window', type: 'ir.actions.act_window',
res_model: this._thread.getDocumentModel(), res_model: this._thread.getDocumentModel(),
@@ -305,6 +328,30 @@ var ThreadWindow = AbstractThreadWindow.extend({
this.toggleFold(false); 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 * @private
* @param {integer} messageID * @param {integer} messageID
@@ -75,6 +75,7 @@ $o-mail-thread-window-zindex: $zindex-modal + 1 !default;
flex: 1 1 auto; flex: 1 1 auto;
overflow: auto; overflow: auto;
-webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari) -webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari)
.o_thread_date_separator { .o_thread_date_separator {
margin: 0px 0px 15px 0px; margin: 0px 0px 15px 0px;
.o_thread_date { .o_thread_date {
-1
View File
@@ -127,7 +127,6 @@
.o_mail_thread { .o_mail_thread {
flex: 1 0 0; flex: 1 0 0;
overflow: auto; overflow: auto;
padding: 0 0 15px 0;
-webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari) -webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari)
} }
+31
View File
@@ -3,6 +3,37 @@
text-align: center; 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 { .o_thread_date_separator {
margin-top: 15px; margin-top: 15px;
margin-bottom: 30px; margin-bottom: 30px;
@@ -15,7 +15,7 @@
</div> </div>
<div class="o_thread_window_content"> <div class="o_thread_window_content">
</div> </div>
<div t-if='widget.hasThread()' class="o_thread_composer o_chat_mini_composer"> <div t-if="widget.needsComposer()" class="o_thread_composer o_chat_mini_composer">
<input class="o_composer_text_field" t-att-placeholder="widget.options.placeholder"/> <input class="o_composer_text_field" t-att-placeholder="widget.options.placeholder"/>
</div> </div>
</div> </div>
+7 -7
View File
@@ -40,7 +40,7 @@
<t t-set="counter" t-value="starredCounter"/> <t t-set="counter" t-value="starredCounter"/>
<t t-call="mail.discuss.SidebarCounter"/> <t t-call="mail.discuss.SidebarCounter"/>
</div> </div>
<div t-if="isModerator" t-attf-class="o_mail_discuss_title_main o_mail_discuss_item #{(activeThreadID == 'mailbox_moderation') ? 'o_active': ''}" <div t-if="isMyselfModerator" t-attf-class="o_mail_discuss_title_main o_mail_discuss_item #{(activeThreadID == 'mailbox_moderation') ? 'o_active': ''}"
data-thread-id="mailbox_moderation"> data-thread-id="mailbox_moderation">
<span class="o_thread_name"> <i class="fa fa-envelope mr8"/>Moderation Queue</span> <span class="o_thread_name"> <i class="fa fa-envelope mr8"/>Moderation Queue</span>
<t t-set="counter" t-value="moderationCounter"/> <t t-set="counter" t-value="moderationCounter"/>
@@ -58,7 +58,7 @@
<t t-set="inputPlaceholder">Add a channel</t> <t t-set="inputPlaceholder">Add a channel</t>
</t> </t>
<t t-set="type" t-value="'dm'"/> <t t-set="type" t-value="'dm_chat'"/>
<t t-call="mail.discuss.SidebarTitle"> <t t-call="mail.discuss.SidebarTitle">
<t t-set="title">Direct Messages</t> <t t-set="title">Direct Messages</t>
<t t-set="icon" t-value="fa-user"/> <t t-set="icon" t-value="fa-user"/>
@@ -133,7 +133,7 @@
<input type="text" class="o_input" t-attf-placeholder="#{inputPlaceholder}"/> <input type="text" class="o_input" t-attf-placeholder="#{inputPlaceholder}"/>
</div> </div>
<t t-foreach="threads" t-as="thread"> <t t-foreach="threads" t-as="thread">
<t t-set="counter" t-value="thread.getType() === 'mailbox' ? thread.getMailboxCounter() : thread.isChat() ? thread.getUnreadCounter() : thread.getNeedactionCounter()"/> <t t-set="counter" t-value="thread.getType() === 'mailbox' ? thread.getMailboxCounter() : thread.isTwoUserThread() ? thread.getUnreadCounter() : thread.getNeedactionCounter()"/>
<div t-if="thread.getType() === type" t-att-data-thread-id="thread.getID()" t-att-title="thread.getName()" <div t-if="thread.getType() === type" t-att-data-thread-id="thread.getID()" t-att-title="thread.getName()"
t-attf-class="o_mail_discuss_item #{thread.hasUnreadMessages() ? ' o_unread_message' : ''} #{(activeThreadID == thread.getID()) ? 'o_active': ''}"> t-attf-class="o_mail_discuss_item #{thread.hasUnreadMessages() ? ' o_unread_message' : ''} #{(activeThreadID == thread.getID()) ? 'o_active': ''}">
<span class="o_thread_name"> <span class="o_thread_name">
@@ -221,7 +221,7 @@
<span class="fa fa-inbox"/> <span class="fa fa-inbox"/>
<span class="o_tab_title">Inbox</span> <span class="o_tab_title">Inbox</span>
</div> </div>
<div class="o_mail_mobile_tab" data-type="dm"> <div class="o_mail_mobile_tab" data-type="dm_chat">
<span class="fa fa-user"/> <span class="fa fa-user"/>
<span class="o_tab_title">Chat</span> <span class="o_tab_title">Chat</span>
</div> </div>
@@ -246,7 +246,7 @@
<div t-if="!disableAddThread" class="o_mail_add_thread" t-att-data-type="type"> <div t-if="!disableAddThread" class="o_mail_add_thread" t-att-data-type="type">
<span t-if="type == 'private' || type == 'public'">#</span> <span t-if="type == 'private' || type == 'public'">#</span>
<t t-set="inputPlaceholder" t-if="type == 'private' || type == 'public'">Add a channel</t> <t t-set="inputPlaceholder" t-if="type == 'private' || type == 'public'">Add a channel</t>
<t t-set="inputPlaceholder" t-if="type == 'dm'">Open chat</t> <t t-set="inputPlaceholder" t-if="type == 'dm_chat'">Open chat</t>
<input type="text" t-attf-placeholder="#{inputPlaceholder}"/> <input type="text" t-attf-placeholder="#{inputPlaceholder}"/>
</div> </div>
<t t-foreach="previews" t-as="preview"> <t t-foreach="previews" t-as="preview">
@@ -266,7 +266,7 @@
@param {string} [preview.documentModel] @param {string} [preview.documentModel]
@param {string|integer} preview.id @param {string|integer} preview.id
@param {string} [preview.imageSRC] @param {string} [preview.imageSRC]
@param {boolean} [preview.isAuthor] @param {boolean} [preview.isMyselfAuthor]
@param {moment} [preview.date] @param {moment} [preview.date]
@param {string} [preview.author] @param {string} [preview.author]
@param {string} [preview.body] @param {string} [preview.body]
@@ -293,7 +293,7 @@
<span class="o_last_message_date"> <t t-esc="preview.date ? preview.date.fromNow() : ''"/> </span> <span class="o_last_message_date"> <t t-esc="preview.date ? preview.date.fromNow() : ''"/> </span>
</div> </div>
<div t-if="preview.body" class="o_last_message_preview"> <div t-if="preview.body" class="o_last_message_preview">
<t t-if="preview.isAuthor"> <t t-if="preview.isMyselfAuthor">
<span class="fa fa-mail-reply" role="img" aria-label="Reply"/> You: <span class="fa fa-mail-reply" role="img" aria-label="Reply"/> You:
</t> </t>
<t t-elif="preview.author"> <t t-elif="preview.author">
+83 -16
View File
@@ -3,22 +3,13 @@
<!-- <!--
@param {mail.model.AbstractThread} thread @param {mail.model.AbstractThread} thread
@param {Object} options
@param {boolean} [options.displayEmptyThread] @param {boolean} [options.displayEmptyThread]
@param {boolean} [options.displayLoadMore]
@param {boolean} [options.displayNoMatchFound] @param {boolean} [options.displayNoMatchFound]
@param {integer} [options.displayOrder] 1 or -1
@param {Object} ORDER
@param {integer} ORDER.ASC=1
@param {integer} ORDER.DESC=-1
--> -->
<t t-name="mail.widget.Thread"> <t t-name="mail.widget.Thread">
<t t-if="thread.hasMessages()"> <t t-if="thread.hasMessages()">
<t t-set="messages" t-value="thread.getMessages()"/>
<t t-if="options.displayLoadMore and options.displayOrder == ORDER.ASC"
t-call="mail.widget.Thread.LoadMore"/>
<t t-call="mail.widget.Thread.Content"/> <t t-call="mail.widget.Thread.Content"/>
<t t-if="options.displayLoadMore and options.displayOrder == ORDER.DESC"
t-call="mail.widget.Thread.LoadMore"/>
</t> </t>
<t t-elif="options.displayEmptyThread"> <t t-elif="options.displayEmptyThread">
<t t-call="mail.widget.Thread.Empty" /> <t t-call="mail.widget.Thread.Empty" />
@@ -134,14 +125,90 @@
</div> </div>
<!-- <!--
@param {mail.model.AbstractMessage[]} messages messages ordered by increasing IDs @param {mail.model.AbstractThread} thread
@param {string|integer} [options.messagesSeparatorPosition] 'top' or @param {Object} options
message ID, the separator is placed just after this message. @param {integer} [options.displayOrder] 1 or -1 chronological order for the thread messages
@param {Object} ORDER
@param {integer} ORDER.ASC=1 messages are ordered by increasing IDs, from bottom to top
@param {integer} ORDER.DESC=-1 messages are ordered by increasing IDs, from top to bottom
Ordering of messages in the thread, messages are indexed by increasing IDs:
_____________ _____________
| | | |
| message 1 | | message n |
| message 2 | | ... |
| ... | | message 2 |
| message n | | message 1 |
|_____________| |_____________|
ORDER: DESC ASC
--> -->
<t t-name="mail.widget.Thread.Content"> <t t-name="mail.widget.Thread.Content">
<t t-if="options.messagesSeparatorPosition == 'top'" t-call="mail.MessagesSeparator"/> <t t-set="messages" t-value="thread.getMessages()"/>
<t t-if="options.displayOrder === ORDER.ASC" t-call="mail.widget.Thread.Content.ASC"/>
<t t-else="" t-call="mail.widget.Thread.Content.DESC"/>
</t>
<!--
@param {mail.model.AbstractThread} thread
@param {Object} options
@param {boolean} [options.displayLoadMore=false]
@param {string|integer} [options.messagesSeparatorPosition] 'top' or
message ID, the separator is placed just after this message.
_____________
| |
| message n |
| ... |
| message 2 |
| message 1 |
|_____________|
ASC Order
-->
<t t-name="mail.widget.Thread.Content.ASC">
<div class="o_mail_thread_content_asc">
<t t-if="options.messagesSeparatorPosition == 'top'" t-call="mail.MessagesSeparator"/>
<t t-set="messages" t-value="messages.slice().reverse()"/>
<t t-call="mail.widget.Thread.Messages"/>
<t t-if="options.displayLoadMore" t-call="mail.widget.Thread.LoadMore"/>
</div>
</t>
<!--
@param {mail.model.AbstractThread} thread
@param {Object} options
@param {boolean} [options.displayBottomThreadFreeSpace=false]
@param {boolean} [options.displayLoadMore=false]
_____________
| |
| message 1 |
| message 2 |
| ... |
| message n |
|_____________|
DESC Order
-->
<t t-name="mail.widget.Thread.Content.DESC">
<div class="o_mail_thread_content_desc">
<t t-if="options.displayLoadMore" t-call="mail.widget.Thread.LoadMore"/>
<t t-call="mail.widget.Thread.Messages"/>
<t t-if="options.displayBottomThreadFreeSpace">
<div class="o_thread_bottom_free_space"/>
</t>
</div>
</t>
<!--
@param {mail.model.AbstractMessage[]} messages messages are ordered based
on desired display order
-->
<t t-name="mail.widget.Thread.Messages">
<t t-set="current_day" t-value="0"/> <t t-set="current_day" t-value="0"/>
<t t-if="options.displayOrder === ORDER.DESC" t-set="messages" t-value="messages.slice().reverse()"/>
<t t-foreach="messages" t-as="message"> <t t-foreach="messages" t-as="message">
<div t-if="current_day !== message.getDateDay()" class="o_thread_date_separator"> <div t-if="current_day !== message.getDateDay()" class="o_thread_date_separator">
<span class="o_thread_date"> <span class="o_thread_date">
@@ -261,7 +328,7 @@
<t t-if="message.needsModerationByUser()"> <t t-if="message.needsModerationByUser()">
<t t-call="mail.MessageModerationDecision"/> <t t-call="mail.MessageModerationDecision"/>
</t> </t>
<t t-elif="message.isAuthor()"> <t t-elif="message.isMyselfAuthor()">
<t t-call="mail.MessageModerationFeedback"/> <t t-call="mail.MessageModerationFeedback"/>
</t> </t>
</t> </t>
@@ -47,9 +47,9 @@ QUnit.test('mobile basic rendering', function (assert) {
"should be in inbox"); "should be in inbox");
// move to DMs tab // move to DMs tab
discuss.$('.o_mail_mobile_tab[data-type=dm]').click(); discuss.$('.o_mail_mobile_tab[data-type=dm_chat]').click();
assert.ok(discuss.$('.o_mail_mobile_tab[data-type=dm]').hasClass('active'), assert.ok(discuss.$('.o_mail_mobile_tab[data-type=dm_chat]').hasClass('active'),
"should be in DMs tab"); "should be in DMs chat tab");
assert.strictEqual(discuss.$('.o_mail_discuss_content .o_mail_no_content').length, 0, assert.strictEqual(discuss.$('.o_mail_discuss_content .o_mail_no_content').length, 0,
"should display the no content message"); "should display the no content message");
$('.o_mail_discuss_button_dm').click(); // click to add a channel $('.o_mail_discuss_button_dm').click(); // click to add a channel
@@ -6,7 +6,7 @@ var mailTestUtils = require('mail.testUtils');
var createDiscuss = mailTestUtils.createDiscuss; var createDiscuss = mailTestUtils.createDiscuss;
QUnit.module('mail', {}, function () { QUnit.module('mail', {}, function () {
QUnit.module('Discuss moderation', { QUnit.module('Discuss (Moderation)', {
beforeEach: function () { beforeEach: function () {
// patch _.debounce and _.throttle to be fast and synchronous // patch _.debounce and _.throttle to be fast and synchronous
this.underscoreDebounce = _.debounce; 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, assert.strictEqual($(moderateAllSelector + '[data-decision="accept"]').length, 1,
"there should one moderate button to accept messages pending moderation"); "there should one moderate button to accept messages pending moderation");
assert.strictEqual($(moderateAllSelector + '[data-decision="accept"]').attr('style'), 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, assert.strictEqual($(moderateAllSelector + '[data-decision="reject"]').length, 1,
"there should one moderate button to reject messages pending moderation"); "there should one moderate button to reject messages pending moderation");
assert.strictEqual($(moderateAllSelector + '[data-decision="reject"]').attr('style'), 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"); "the 'Unselect All' button should not be disabled");
// check moderate all buttons updated (visible) // check moderate all buttons updated (visible)
assert.strictEqual($(moderateAllSelector + '[data-decision="accept"]').attr('style'), 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'), 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'), 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' // 2. go to channel 'general'
discuss.$('.o_mail_discuss_item[data-thread-id="1"]').click(); discuss.$('.o_mail_discuss_item[data-thread-id="1"]').click();
+18 -8
View File
@@ -11,7 +11,6 @@ var testUtils = require('web.test_utils');
var createDiscuss = mailTestUtils.createDiscuss; var createDiscuss = mailTestUtils.createDiscuss;
QUnit.module('mail', {}, function () { QUnit.module('mail', {}, function () {
QUnit.module('Discuss', { QUnit.module('Discuss', {
beforeEach: function () { beforeEach: function () {
// patch _.debounce and _.throttle to be fast and synchronous // patch _.debounce and _.throttle to be fast and synchronous
@@ -181,7 +180,7 @@ QUnit.test('@ mention in channel', function (assert) {
body: args.kwargs.body, body: args.kwargs.body,
channel_ids: [1], channel_ids: [1],
}; };
var notification = [[false, 'mail.channel'], data]; var notification = [[false, 'mail.channel', 1], data];
objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]); objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]);
receiveMessageDef.resolve(); receiveMessageDef.resolve();
return $.when(42); return $.when(42);
@@ -204,7 +203,6 @@ QUnit.test('@ mention in channel', function (assert) {
var $input = discuss.$('textarea.o_composer_text_field').first(); var $input = discuss.$('textarea.o_composer_text_field').first();
assert.ok($input.length, "should display a composer input"); 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' // Note: focus is needed in order to trigger rpc 'channel_fetch_listeners'
$input.focus(); $input.focus();
$input.val("@"); $input.val("@");
@@ -309,7 +307,6 @@ QUnit.test('@ mention in channel', function (assert) {
assert.strictEqual(discuss.$('.o_thread_message_content a').text(), assert.strictEqual(discuss.$('.o_thread_message_content a').text(),
"@Admin", "should have correct mention link in the message content"); "@Admin", "should have correct mention link in the message content");
// Restore window.getSelection
discuss.destroy(); discuss.destroy();
done(); done();
}); });
@@ -360,7 +357,7 @@ QUnit.test('no crash focusout emoji button', function (assert) {
}); });
QUnit.test('older messages are loaded on scroll', function (assert) { QUnit.test('older messages are loaded on scroll', function (assert) {
assert.expect(3); assert.expect(10);
var done = assert.async(); var done = assert.async();
var fetchCount = 0; var fetchCount = 0;
@@ -394,10 +391,15 @@ QUnit.test('older messages are loaded on scroll', function (assert) {
services: this.services, services: this.services,
mockRPC: function (route, args) { mockRPC: function (route, args) {
if (args.method === 'message_fetch') { if (args.method === 'message_fetch') {
assert.step(args.method);
fetchCount++; fetchCount++;
// 1st fetch: inbox initial fetch // 1st fetch: inbox initial fetch
// 2nd fetch: general initial fetch // 2nd fetch: general initial fetch
// 3rd fetch: general load more // 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) { if (fetchCount === 3) {
loadMoreDef.resolve(); loadMoreDef.resolve();
} }
@@ -405,6 +407,10 @@ QUnit.test('older messages are loaded on scroll', function (assert) {
return this._super.apply(this, arguments); return this._super.apply(this, arguments);
}, },
}).then(function (discuss) { }).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]'); var $general = discuss.$('.o_mail_discuss_item[data-thread-id=1]');
assert.strictEqual($general.length, 1, assert.strictEqual($general.length, 1,
"should have a channel item with id 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' // switch to 'general'
$general.click(); $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, assert.strictEqual(discuss.$('.o_thread_message').length, 30,
"should display the 30 messages"); "should display the 30 messages");
@@ -421,6 +430,8 @@ QUnit.test('older messages are loaded on scroll', function (assert) {
loadMoreDef loadMoreDef
.then(concurrency.delay.bind(concurrency, 0)) .then(concurrency.delay.bind(concurrency, 0))
.then(function () { .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, assert.strictEqual(discuss.$('.o_thread_message').length, 35,
"all messages should now be loaded"); "all messages should now be loaded");
@@ -621,7 +632,6 @@ QUnit.test('confirm dialog when administrator leave (not chat) channel', functio
discuss.destroy(); discuss.destroy();
done(); done();
}); });
}); });
QUnit.test('convert emoji sources to unicodes on message_post', function (assert) { 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, body: args.kwargs.body,
channel_ids: [1], channel_ids: [1],
}; };
var notification = [[false, 'mail.channel'], data]; var notification = [[false, 'mail.channel', 1], data];
objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]); objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]);
receiveMessageDef.resolve(); receiveMessageDef.resolve();
return $.when(42); return $.when(42);
@@ -691,6 +701,6 @@ QUnit.test('convert emoji sources to unicodes on message_post', function (assert
}); });
}); });
}); });
});
}); });
});
@@ -8,7 +8,7 @@ var testUtils = require('web.test_utils');
QUnit.module('mail', {}, function () { QUnit.module('mail', {}, function () {
QUnit.module('DocumentThreadWindow', { QUnit.module('ThreadWindow (Document Thread)', {
beforeEach: function () { beforeEach: function () {
var partnerID = 44; var partnerID = 44;
this.data = { this.data = {
@@ -155,6 +155,9 @@ MockServer.include({
if (args.method === 'channel_fetch_preview') { if (args.method === 'channel_fetch_preview') {
return $.when(this._mockChannelFetchPreview(args)); return $.when(this._mockChannelFetchPreview(args));
} }
if (args.method === 'channel_minimize') {
return $.when();
}
if (args.method === 'channel_seen') { if (args.method === 'channel_seen') {
return $.when(); return $.when();
} }
@@ -173,7 +176,7 @@ MockServer.include({
if (args.method === 'moderate') { if (args.method === 'moderate') {
return $.when(this._mockModerate(args)); return $.when(this._mockModerate(args));
} }
if (args.method === 'channel_minimize') { if (args.method === 'set_message_done') {
return $.when(); return $.when();
} }
return this._super(route, args); return this._super(route, args);
@@ -6,7 +6,6 @@ var BusService = require('bus.BusService');
var Discuss = require('mail.Discuss'); var Discuss = require('mail.Discuss');
var MailService = require('mail.Service'); var MailService = require('mail.Service');
var AbstractService = require('web.AbstractService');
var AbstractStorageService = require('web.AbstractStorageService'); var AbstractStorageService = require('web.AbstractStorageService');
var Class = require('web.Class'); var Class = require('web.Class');
var ControlPanel = require('web.ControlPanel'); var ControlPanel = require('web.ControlPanel');
@@ -67,6 +66,7 @@ var MockMailService = Class.extend({
return BusService.extend({ return BusService.extend({
_poll: function () {}, // Do nothing _poll: function () {}, // Do nothing
isOdooFocused: function () { return true; }, isOdooFocused: function () { return true; },
updateOption: function () {},
}); });
}, },
mail_service: function () { mail_service: function () {
@@ -8,9 +8,14 @@ var testUtils = require('web.test_utils');
var Widget = require('web.Widget'); var Widget = require('web.Widget');
QUnit.module('mail', { QUnit.module('mail', {
beforeEach: function () { beforeEach: function (assert) {
var self = this; 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 // define channel to link to chat window
this.data = { this.data = {
'mail.message': { 'mail.message': {
@@ -34,6 +39,8 @@ QUnit.module('mail', {
var widget = new Widget(); var widget = new Widget();
// in non-debug mode, append thread windows in qunit-fixture // 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) { if (params.debug) {
self.services.mail_service.prototype.THREAD_WINDOW_APPENDTO = 'body'; self.services.mail_service.prototype.THREAD_WINDOW_APPENDTO = 'body';
} else { } else {
@@ -50,10 +57,9 @@ QUnit.module('mail', {
}, },
}, function () { }, function () {
QUnit.module('thread_window'); QUnit.module('ThreadWindow');
QUnit.test('close thread window using ESCAPE key', function (assert) { 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({ var parent = this.createParent({
data: this.data, 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) { 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({ var parent = this.createParent({
data: this.data, data: this.data,
@@ -124,7 +130,7 @@ QUnit.module('mail', {
}); });
QUnit.test('emoji popover should open correctly in thread windows', function (assert) { 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({ var parent = this.createParent({
data: this.data, data: this.data,
@@ -147,5 +153,6 @@ QUnit.module('mail', {
done(); done();
}, 0); }, 0);
}); });
}); });
}); });
+7 -3
View File
@@ -24,13 +24,17 @@
<script type="text/javascript" src="/mail/static/src/js/models/messages/message.js"></script> <script type="text/javascript" src="/mail/static/src/js/models/messages/message.js"></script>
<!-- threads --> <!-- threads -->
<script type="text/javascript" src="/mail/static/src/js/models/threads/abstract_thread.js"></script> <script type="text/javascript" src="/mail/static/src/js/models/threads/abstract_thread.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/threads/channel.js"></script> <!-- channels -->
<script type="text/javascript" src="/mail/static/src/js/models/threads/channel.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/threads/multi_user_channel.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/threads/two_user_channel.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/threads/create_mode_document_thread.js"></script> <script type="text/javascript" src="/mail/static/src/js/models/threads/create_mode_document_thread.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/threads/dm.js"></script> <script type="text/javascript" src="/mail/static/src/js/models/threads/dm_chat.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/threads/document_thread.js"></script> <script type="text/javascript" src="/mail/static/src/js/models/threads/document_thread.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/threads/livechat.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/threads/mailbox.js"></script> <script type="text/javascript" src="/mail/static/src/js/models/threads/mailbox.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/threads/searchable_thread.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/threads/thread.js"></script> <script type="text/javascript" src="/mail/static/src/js/models/threads/thread.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/threads/thread_with_cache.js"></script>
<!-- Widgets --> <!-- Widgets -->
<!-- thread windows --> <!-- thread windows -->
<script type="text/javascript" src="/mail/static/src/js/thread_windows/abstract_thread_window.js"></script> <script type="text/javascript" src="/mail/static/src/js/thread_windows/abstract_thread_window.js"></script>