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