summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
authorAlexandre Kühn <aku@odoo.com>2018-08-11 21:02:55 +0200
committerAlexandre Kühn <aku@odoo.com>2018-08-13 14:17:40 +0200
commit5f1d32f3fb89f40740b25491caafb9a5710d555a (patch)
treed94aab4d0a6fb36d60079391216b3dd4c89b23c7 /addons
parent614f9f84d7da281b0feacc39a612c19193f3a4d8 (diff)
[IMP] mail, im_livechat: 'is typing...' indicator on mail channels
With this commit, most threads show an indicator when a member is typing something. e.g. "Mitchell Stephens is typing..." Supported threads: - DM - (public & private) channels - Livechat (Website & Backend) Unsupported threads: - mailboxes (e.g. 'Inbox') - document thread (= 'Chatter') - support channel (with `im_support` module) Some technical details: - This is a mix between 'start'/'stop' and 'regular notify' strategies. - On typing text in composer: notify 'start typing' to all members of thread. - On clearing text in composer: notify 'stop typing' to all members of thread. - A member can only notify once every 2.5 seconds to the server when he starts or stops typing something (throttled, buffered notify call). - On receiving a message from someone that is typing: determine that he is no longer typing. - On non-empty and unchanged composer text for 5.0 seconds: automatically notifies all members of thread that he is no longer typing anything. (This is like a 'soft / cooperative sender' timeout). - On non-receiving a typing notification of someone after 60 seconds: determine that he is not longer typing something (This is like a 'hard / receiver assumption' timeout). - When some types for more than 50 seconds straight: notify that he is still typing something (again and again every 50 seconds). - When two or more people are typing something (Multi-User channels), it displays at most 2 typing users (ordering rule: longer typers first): "Mitchell Stephens, Marc Brown and more are typing..." - Timing summary: - Usually 2.5 seconds uncertainty. - Up to 5.0 seconds uncertainty on typer inactivity. - Up to 60 seconds uncertainty on typer page reload / browser tab closed / etc. Future Improvements: - Detect page reload or browser tab close with loose of longpolling connection Task-ID 28188
Diffstat (limited to 'addons')
-rw-r--r--addons/im_livechat/controllers/main.py10
-rw-r--r--addons/im_livechat/static/src/js/im_livechat.js23
-rw-r--r--addons/im_livechat/static/src/js/models/website_livechat.js66
-rw-r--r--addons/im_livechat/static/src/js/website_livechat_window.js19
-rw-r--r--addons/im_livechat/views/im_livechat_channel_templates.xml5
-rw-r--r--addons/mail/models/mail_channel.py21
-rw-r--r--addons/mail/static/src/js/composers/basic_composer.js20
-rw-r--r--addons/mail/static/src/js/discuss.js22
-rw-r--r--addons/mail/static/src/js/models/threads/abstract_thread.js10
-rw-r--r--addons/mail/static/src/js/models/threads/channel.js39
-rw-r--r--addons/mail/static/src/js/models/threads/livechat.js42
-rw-r--r--addons/mail/static/src/js/models/threads/thread_typing_mixin/cc_throttle_function.js181
-rw-r--r--addons/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js344
-rw-r--r--addons/mail/static/src/js/models/threads/thread_typing_mixin/timer.js69
-rw-r--r--addons/mail/static/src/js/models/threads/thread_typing_mixin/timers.js82
-rw-r--r--addons/mail/static/src/js/services/mail_notification_manager.js56
-rw-r--r--addons/mail/static/src/js/services/mail_window_manager.js20
-rw-r--r--addons/mail/static/src/js/thread_widget.js28
-rw-r--r--addons/mail/static/src/js/thread_windows/abstract_thread_window.js8
-rw-r--r--addons/mail/static/src/scss/abstract_thread_window.scss6
-rw-r--r--addons/mail/static/src/scss/thread.scss19
-rw-r--r--addons/mail/static/src/xml/thread.xml11
-rw-r--r--addons/mail/static/tests/discuss_typing_notification_tests.js604
-rw-r--r--addons/mail/static/tests/helpers/mock_server.js3
-rw-r--r--addons/mail/static/tests/models/cc_throttle_function_tests.js155
-rw-r--r--addons/mail/static/tests/models/timer_tests.js88
-rw-r--r--addons/mail/static/tests/models/timers_tests.js90
-rw-r--r--addons/mail/views/mail_templates.xml10
-rw-r--r--addons/web/static/src/scss/utils.scss20
-rw-r--r--addons/website_livechat/views/website_livechat.xml5
30 files changed, 2068 insertions, 8 deletions
diff --git a/addons/im_livechat/controllers/main.py b/addons/im_livechat/controllers/main.py
index 79a5f28b99b..4ba6a731581 100644
--- a/addons/im_livechat/controllers/main.py
+++ b/addons/im_livechat/controllers/main.py
@@ -115,3 +115,13 @@ class LivechatController(http.Controller):
if channel:
channel._send_history_message(pid, page_history)
return True
+
+ @http.route('/im_livechat/notify_typing', type='json', auth='public')
+ def notify_typing(self, uuid, is_typing):
+ """ Broadcast the typing notification of the website user to other channel members
+ :param uuid: (string) the UUID of the livechat channel
+ :param is_typing: (boolean) tells whether the website user is typing or not.
+ """
+ Channel = request.env['mail.channel']
+ channel = Channel.sudo().search([('uuid', '=', uuid)], limit=1)
+ channel.notify_typing(is_typing=is_typing, is_website_user=True)
diff --git a/addons/im_livechat/static/src/js/im_livechat.js b/addons/im_livechat/static/src/js/im_livechat.js
index 2a637f4b7d6..b8941eb4239 100644
--- a/addons/im_livechat/static/src/js/im_livechat.js
+++ b/addons/im_livechat/static/src/js/im_livechat.js
@@ -48,6 +48,7 @@ var LivechatButton = Widget.extend({
'close_chat_window': '_onCloseChatWindow',
'post_message_chat_window': '_onPostMessageChatWindow',
'save_chat_window': '_onSaveChatWindow',
+ 'updated_typing_partners': '_onUpdatedTypingPartners',
'updated_unread_counter': '_onUpdatedUnreadCounter',
},
events: {
@@ -122,6 +123,9 @@ var LivechatButton = Widget.extend({
serverURL: this._serverURL,
});
var message = new WebsiteLivechatMessage(this, data, options);
+ if (this._livechat) {
+ this._livechat.addMessage(message);
+ }
if (options && options.prepend) {
this._messages.unshift(message);
@@ -162,6 +166,17 @@ var LivechatButton = Widget.extend({
channel_uuid: this._livechat.getUUID(),
page_history: history,
});
+ } else if (notification[1].info === 'typing_status') {
+ var isWebsiteUser = notification[1].is_website_user;
+ if (isWebsiteUser) {
+ return; // do not handle typing status notification of myself
+ }
+ var partnerID = notification[1].partner_id;
+ if (notification[1].is_typing) {
+ this._livechat.registerTyping({ partnerID: partnerID });
+ } else {
+ this._livechat.unregisterTyping({ partnerID: partnerID });
+ }
} else { // normal message
this._addMessage(notification[1]);
this._renderMessages();
@@ -343,6 +358,14 @@ var LivechatButton = Widget.extend({
* @private
* @param {OdooEvent} ev
*/
+ _onUpdatedTypingPartners: function (ev) {
+ ev.stopPropagation();
+ this._chatWindow.renderTypingNotificationBar();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
_onUpdatedUnreadCounter: function (ev) {
ev.stopPropagation();
this._chatWindow.renderHeader();
diff --git a/addons/im_livechat/static/src/js/models/website_livechat.js b/addons/im_livechat/static/src/js/models/website_livechat.js
index 758f90b302c..faae45feac5 100644
--- a/addons/im_livechat/static/src/js/models/website_livechat.js
+++ b/addons/im_livechat/static/src/js/models/website_livechat.js
@@ -2,6 +2,7 @@ odoo.define('im_livechat.model.WebsiteLivechat', function (require) {
"use strict";
var AbstractThread = require('mail.model.AbstractThread');
+var ThreadTypingMixin = require('mail.model.ThreadTypingMixin');
var session = require('web.session');
@@ -9,7 +10,7 @@ var session = require('web.session');
* Thread model that represents a livechat on the website-side. This livechat
* is not linked to the mail service.
*/
-var WebsiteLivechat = AbstractThread.extend({
+var WebsiteLivechat = AbstractThread.extend(ThreadTypingMixin, {
/**
* @override
@@ -32,6 +33,7 @@ var WebsiteLivechat = AbstractThread.extend({
*/
init: function (params) {
this._super.apply(this, arguments);
+ ThreadTypingMixin.init.call(this, arguments);
this._members = [];
this._operatorPID = params.data.operator_pid;
@@ -46,6 +48,13 @@ var WebsiteLivechat = AbstractThread.extend({
} else {
this._folded = params.data.state === 'folded';
}
+
+ // Necessary for thread typing mixin to display is typing notification
+ // bar text (at least, for the operator in the members).
+ this._members.push({
+ id: this._operatorPID[0],
+ name: this._operatorPID[1]
+ });
},
//--------------------------------------------------------------------------
@@ -108,6 +117,39 @@ var WebsiteLivechat = AbstractThread.extend({
//--------------------------------------------------------------------------
/**
+ * @override {mail.model.ThreadTypingMixin}
+ * @private
+ * @param {Object} params
+ * @param {boolean} params.isWebsiteUser
+ * @returns {boolean}
+ */
+ _isTypingMyselfInfo: function (params) {
+ return params.isWebsiteUser;
+ },
+ /**
+ * @override {mail.model.ThreadTypingMixin}
+ * @private
+ * @param {Object} params
+ * @param {boolean} params.typing
+ * @returns {$.Promise}
+ */
+ _notifyMyselfTyping: function (params) {
+ return session.rpc('/im_livechat/notify_typing', {
+ uuid: this.getUUID(),
+ is_typing: params.typing,
+ }, { shadow: true });
+ },
+ /**
+ * Warn views that the list of users that are currently typing on this
+ * livechat has been updated.
+ *
+ * @override {mail.model.ThreadTypingMixin}
+ * @private
+ */
+ _warnUpdatedTypingPartners: function () {
+ this.trigger_up('updated_typing_partners');
+ },
+ /**
* Warn that the unread counter has been updated on this livechat
*
* @override
@@ -116,6 +158,28 @@ var WebsiteLivechat = AbstractThread.extend({
_warnUpdatedUnreadCounter: function () {
this.trigger_up('updated_unread_counter');
},
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override so that it only unregister typing operators.
+ *
+ * Note that in the frontend, there is no way to identify a message that is
+ * from the current user, because there is no partner ID in the session and
+ * a message with an author sets the partner ID of the author.
+ *
+ * @override {mail.model.ThreadTypingMixin}
+ * @private
+ * @param {mail.model.AbstractMessage} message
+ */
+ _onTypingMessageAdded: function (message) {
+ var operatorID = this.getOperatorPID()[0];
+ if (message.hasAuthor() && message.getAuthorID() === operatorID) {
+ this.unregisterTyping({ partnerID: operatorID });
+ }
+ },
});
return WebsiteLivechat;
diff --git a/addons/im_livechat/static/src/js/website_livechat_window.js b/addons/im_livechat/static/src/js/website_livechat_window.js
index ea5b11f5417..ca20f05764c 100644
--- a/addons/im_livechat/static/src/js/website_livechat_window.js
+++ b/addons/im_livechat/static/src/js/website_livechat_window.js
@@ -9,6 +9,9 @@ var AbstractThreadWindow = require('mail.AbstractThreadWindow');
* @see mail.AbstractThreadWindow for more information
*/
var LivechatWindow = AbstractThreadWindow.extend({
+ events: _.extend(AbstractThreadWindow.prototype.events, {
+ 'input .o_composer_text_field': '_onInput',
+ }),
/**
* @override
* @param {im_livechat.im_livechat.LivechatButton} parent
@@ -63,6 +66,22 @@ var LivechatWindow = AbstractThreadWindow.extend({
this.trigger_up('post_message_chat_window', { messageData: messageData });
this._super.apply(this, arguments);
},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the input in the composer changes
+ *
+ * @private
+ */
+ _onInput: function () {
+ if (this.hasThread() && this._thread.hasTypingNotification()) {
+ var isTyping = this.$input.val().length > 0;
+ this._thread.setMyselfTyping({ typing: isTyping });
+ }
+ },
});
return LivechatWindow;
diff --git a/addons/im_livechat/views/im_livechat_channel_templates.xml b/addons/im_livechat/views/im_livechat_channel_templates.xml
index bc44e4c79fb..95e1bc388da 100644
--- a/addons/im_livechat/views/im_livechat_channel_templates.xml
+++ b/addons/im_livechat/views/im_livechat_channel_templates.xml
@@ -151,6 +151,11 @@
<!-- threads -->
<script type="text/javascript" src="/mail/static/src/js/models/threads/abstract_thread.js"></script>
<script type="text/javascript" src="/im_livechat/static/src/js/models/website_livechat.js"></script>
+ <!-- thread typing mixin -->
+ <script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js"></script>
+ <script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/timer.js"></script>
+ <script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/timers.js"></script>
+ <script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/cc_throttle_function.js"></script>
<!-- messages -->
<script type="text/javascript" src="/mail/static/src/js/models/messages/abstract_message.js"></script>
<script type="text/javascript" src="/im_livechat/static/src/js/models/website_livechat_message.js"></script>
diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py
index 603efd0eecb..1c316673935 100644
--- a/addons/mail/models/mail_channel.py
+++ b/addons/mail/models/mail_channel.py
@@ -727,6 +727,27 @@ class Channel(models.Model):
# broadcast the channel header to the added partner
self._broadcast(partner_ids)
+ @api.multi
+ def notify_typing(self, is_typing, is_website_user=False):
+ """ Broadcast the typing notification to channel members
+ :param is_typing: (boolean) tells whether the current user is typing or not
+ :param is_website_user: (boolean) tells whether the user that notifies comes
+ from the website-side. This is useful in order to distinguish operator and
+ unlogged users for livechat, because unlogged users have the same
+ partner_id as the admin (default: False).
+ """
+ notifications = []
+ for channel in self:
+ data = {
+ 'info': 'typing_status',
+ 'is_typing': is_typing,
+ 'is_website_user': is_website_user,
+ 'partner_id': self.env.user.partner_id.id,
+ }
+ notifications.append([(self._cr.dbname, 'mail.channel', channel.id), data]) # notify backend users
+ notifications.append([channel.uuid, data]) # notify frontend users
+ self.env['bus.bus'].sendmany(notifications)
+
#------------------------------------------------------
# Instant Messaging View Specific (Slack Client Action)
#------------------------------------------------------
diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js
index bba52c3f8b2..a6cdb39e2de 100644
--- a/addons/mail/static/src/js/composers/basic_composer.js
+++ b/addons/mail/static/src/js/composers/basic_composer.js
@@ -28,6 +28,7 @@ var BasicComposer = Widget.extend({
'focusout .o_composer_button_emoji': '_onEmojiButtonFocusout',
'click .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageClick',
'focus .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageFocus',
+ 'input .o_input': '_onInput',
'keydown .o_composer_input textarea': '_onKeydown',
'keyup .o_composer_input': '_onKeyup',
'click .o_composer_button_send': '_sendMessage',
@@ -185,6 +186,14 @@ var BasicComposer = Widget.extend({
this.$input.val(state.text);
},
/**
+ * Set the thread that this composer refers to.
+ *
+ * @param {mail.model.Thread} thread
+ */
+ setThread: function (thread) {
+ this.options.thread = thread;
+ },
+ /**
* Set the list of command suggestions on the thread.
*
* When the user partially types a command, matching commands are displayed
@@ -562,6 +571,17 @@ var BasicComposer = Widget.extend({
clearTimeout(this._hideEmojisTimeout);
},
/**
+ * Called when the input in the composer changes
+ *
+ * @private
+ */
+ _onInput: function () {
+ if (this.options.thread && this.options.thread.hasTypingNotification()) {
+ var isTyping = this.$input.val().length > 0;
+ this.options.thread.setMyselfTyping({ typing: isTyping });
+ }
+ },
+ /**
* _onKeydown event is triggered when is key is pressed
* - on UP and DOWN arrow is pressed then event prevents it's default
* behaviour if mention manager is open else it break it.
diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js
index ede167aa6a5..76350e0e34f 100644
--- a/addons/mail/static/src/js/discuss.js
+++ b/addons/mail/static/src/js/discuss.js
@@ -850,6 +850,9 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
this.action.context.active_id = this._thread.getID();
this.action.context.active_ids = [this._thread.getID()];
+ this._basicComposer.setThread(this._thread);
+ this._extendedComposer.setThread(this._thread);
+
return this._fetchAndRenderThread().then(function () {
// Mark thread's messages as read and clear needactions
if (self._thread.getType() !== 'mailbox') {
@@ -893,7 +896,8 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
.on('update_thread_unread_counter', this, this._throttledUpdateThreads)
.on('update_dm_presence', this, this._throttledUpdateThreads)
.on('activity_updated', this, this._throttledUpdateThreads)
- .on('update_moderation_counter', this, this._throttledUpdateThreads);
+ .on('update_moderation_counter', this, this._throttledUpdateThreads)
+ .on('update_typing_partners', this, this._onTypingPartnersUpdated);
},
/**
* @private
@@ -1421,6 +1425,22 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
},
/**
* @private
+ * @param {integer|string} threadID
+ */
+ _onTypingPartnersUpdated: function (threadID) {
+ var self = this;
+ if (this._thread.getID() !== threadID) {
+ return;
+ }
+ if (this._thread.hasTypingNotification()) {
+ // call getMentionpartnerSuggestions in order to correctly fetch members
+ this._thread.getMentionPartnerSuggestions().then(function () {
+ self._threadWidget.renderTypingNotificationBar(self._thread);
+ });
+ }
+ },
+ /**
+ * @private
* @param {MouseEvent} ev
*/
_onUnpinChannel: function (ev) {
diff --git a/addons/mail/static/src/js/models/threads/abstract_thread.js b/addons/mail/static/src/js/models/threads/abstract_thread.js
index fb6695c6a6c..322541af016 100644
--- a/addons/mail/static/src/js/models/threads/abstract_thread.js
+++ b/addons/mail/static/src/js/models/threads/abstract_thread.js
@@ -103,6 +103,16 @@ var AbstractThread = Class.extend(Mixins.EventDispatcherMixin, {
return !_.isEmpty(this.getMessages());
},
/**
+ * States whether this thread is compatible with the 'is typing...' feature.
+ * By default, threads do not have this feature active.
+ * @see {mail.model.ThreadTypingMixin} to enable this feature on a thread.
+ *
+ * @returns {boolean}
+ */
+ hasTypingNotification: function () {
+ return false;
+ },
+ /**
* States whether this thread is folded or not.
*
* @return {boolean}
diff --git a/addons/mail/static/src/js/models/threads/channel.js b/addons/mail/static/src/js/models/threads/channel.js
index 3f932ccb8ec..8f0f664b6b0 100644
--- a/addons/mail/static/src/js/models/threads/channel.js
+++ b/addons/mail/static/src/js/models/threads/channel.js
@@ -2,6 +2,7 @@ odoo.define('mail.model.Channel', function (require) {
"use strict";
var SearchableThread = require('mail.model.SearchableThread');
+var ThreadTypingMixin = require('mail.model.ThreadTypingMixin');
var mailUtils = require('mail.utils');
var session = require('web.session');
@@ -15,7 +16,7 @@ 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 = SearchableThread.extend({
+var Channel = SearchableThread.extend(ThreadTypingMixin, {
/**
* @override
* @param {Object} params
@@ -38,6 +39,7 @@ var Channel = SearchableThread.extend({
init: function (params) {
var self = this;
this._super.apply(this, arguments);
+ ThreadTypingMixin.init.call(this, arguments);
var data = params.data;
var options = params.options;
@@ -375,6 +377,15 @@ var Channel = SearchableThread.extend({
['channel_ids', 'in', [this._id]]];
},
/**
+ * @override {mail.model.ThreadTypingMixin}
+ * @private
+ * @param {Object} params
+ * @param {integer} params.partnerID
+ */
+ _isTypingMyselfInfo: function (params) {
+ return session.partner_id === params.partnerID;
+ },
+ /**
* 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
@@ -389,6 +400,21 @@ var Channel = SearchableThread.extend({
return $.when(superDef, seenDef);
},
/**
+ * @override {mail.model.ThreadTypingMixin}
+ * @private
+ * @param {Object} params
+ * @param {boolean} params.typing
+ * @returns {$.Promise}
+ */
+ _notifyMyselfTyping: function (params) {
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'notify_typing',
+ args: [this.getID()],
+ kwargs: { is_typing: params.typing },
+ }, { shadow: true });
+ },
+ /**
* Prepare and send a message to the server on this channel.
*
* @override
@@ -415,6 +441,17 @@ var Channel = SearchableThread.extend({
});
});
},
+ /**
+ * Warn views that the list of users that are currently typing on this
+ * thread has been updated.
+ *
+ * @override {mail.model.ThreadTypingMixin}
+ * @private
+ */
+ _warnUpdatedTypingPartners: function () {
+ this.call('mail_service', 'getMailBus')
+ .trigger('update_typing_partners', this.getID());
+ },
});
return Channel;
diff --git a/addons/mail/static/src/js/models/threads/livechat.js b/addons/mail/static/src/js/models/threads/livechat.js
index d2aa04f926a..b7235e7bc0e 100644
--- a/addons/mail/static/src/js/models/threads/livechat.js
+++ b/addons/mail/static/src/js/models/threads/livechat.js
@@ -32,7 +32,9 @@ var Livechat = TwoUserChannel.extend({
//--------------------------------------------------------------------------
/**
- * For the livechat,
+ * Override so that the list of members has the website use. This is
+ * necessary in order for the 'is_typing' feature to compute the name to
+ * display of a user that is typing.
*
* @override
* @returns {$.Promise<Object[]>} resolved with list of livechat members
@@ -50,6 +52,44 @@ var Livechat = TwoUserChannel.extend({
return self._members;
});
},
+ /**
+ * Called when someone starts typing something on the livechat.
+ *
+ * Overrides it so that it determine the partner based on the received
+ * userID. The reason is that anonymous users have the partner ID of the
+ * admin, which is likely also an operator, so userID must be used in order
+ * to distinct them.
+ *
+ * @override {mail.model.ThreadTypingMixin}
+ * @param {Object} params
+ * @param {integer} params.partnerID ID of the partner that is currently
+ * typing something on the thread.
+ * @param {boolean} [params.isWebsiteUser=false] whether the typing partner
+ * is an anonymous user (reminder: they share partnerID with admin).
+ */
+ registerTyping: function (params) {
+ params.partnerID = this._WEBSITE_USER_ID;
+ this._super.call(this, params);
+ },
+ /**
+ * Called when someone stops typing something on the livechat.
+ *
+ * Overrides it so that it determine the partner based on the received
+ * userID. The reason is that anonymous users have the partner ID of the
+ * admin, which is likely also an operator, so userID must be used in order
+ * to distinct them.
+ *
+ * @override {mail.model.ThreadTypingMixin}
+ * @param {Object} params
+ * @param {integer} params.partnerID ID of the partner that stops typing
+ * something on the thread.
+ * @param {boolean} [params.isWebsiteUser=false] whether the typing partner
+ * is an anonymous user (reminder: they share partnerID with admin).
+ */
+ unregisterTyping: function (params) {
+ params.partnerID = this._WEBSITE_USER_ID;
+ this._super.call(this, params);
+ },
});
return Livechat;
diff --git a/addons/mail/static/src/js/models/threads/thread_typing_mixin/cc_throttle_function.js b/addons/mail/static/src/js/models/threads/thread_typing_mixin/cc_throttle_function.js
new file mode 100644
index 00000000000..b4b87087e6d
--- /dev/null
+++ b/addons/mail/static/src/js/models/threads/thread_typing_mixin/cc_throttle_function.js
@@ -0,0 +1,181 @@
+odoo.define('mail.model.CCThrottleFunctionObject', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+
+/**
+ * This object models the behaviour of the clearable and cancellable (CC)
+ * throttle version of a provided function.
+ */
+var CCThrottleFunctionObject = Class.extend({
+
+ /**
+ * @param {Object} params
+ * @param {integer} params.duration duration of the 'cooldown' phase, i.e.
+ * the minimum duration between the most recent function call that has
+ * been made and the following function call.
+ * @param {function} params.func provided function for making the CC
+ * throttled version.
+ */
+ init: function (params) {
+ this._arguments = undefined;
+ this._cooldownTimeout = undefined;
+ this._duration = params.duration;
+ this._func = params.func;
+ this._shouldCallFunctionAfterCD = false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Cancel any buffered function call, but keep the cooldown phase running.
+ */
+ cancel: function () {
+ this._arguments = undefined;
+ this._shouldCallFunctionAfterCD = false;
+ },
+ /**
+ * Clear the internal throttle timer, so that the following function call
+ * is immediate. For instance, if there is a cooldown stage, it is aborted.
+ */
+ clear: function () {
+ if (this._cooldownTimeout) {
+ clearTimeout(this._cooldownTimeout);
+ this._onCooldownTimeout();
+ }
+ },
+ /**
+ * Called when there is a call to the function. This function is throttled,
+ * so the time it is called depends on whether the "cooldown stage" occurs
+ * or not:
+ *
+ * - no cooldown stage: function is called immediately, and it starts
+ * the cooldown stage when successful.
+ * - in cooldown stage: function is called when the cooldown stage has
+ * ended from timeout.
+ *
+ * Note that after the cooldown stage, only the last attempted function
+ * call will be considered.
+ */
+ do: function () {
+ this._arguments = Array.prototype.slice.call(arguments);
+ if (this._cooldownTimeout === undefined) {
+ this._callFunction();
+ } else {
+ this._shouldCallFunctionAfterCD = true;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Immediately calls the function with arguments of last buffered function
+ * call. It initiates the cooldown stage after this function call.
+ *
+ * @private
+ */
+ _callFunction: function () {
+ this._func.apply(null, this._arguments);
+ this._cooldown();
+ },
+ /**
+ * Called when the function has been successfully called. The following
+ * calls to the function with this object should suffer a "cooldown stage",
+ * which prevents the function from being called until this stage has ended.
+ *
+ * @private
+ */
+ _cooldown: function () {
+ this.cancel();
+ this._cooldownTimeout = setTimeout(
+ this._onCooldownTimeout.bind(this),
+ this._duration
+ );
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the cooldown stage ended from timeout. Calls the function if
+ * a function call was buffered.
+ *
+ * @private
+ */
+ _onCooldownTimeout: function () {
+ if (this._shouldCallFunctionAfterCD) {
+ this._callFunction();
+ } else {
+ this._cooldownTimeout = undefined;
+ }
+ },
+});
+
+return CCThrottleFunctionObject;
+
+});
+
+
+odoo.define('mail.model.CCThrottleFunction', function (require) {
+"use strict";
+
+var CCThrottleFunctionObject = require('mail.model.CCThrottleFunctionObject');
+
+/**
+ * A function that creates a cancellable and clearable (CC) throttle version
+ * of a provided function.
+ *
+ * This throttle mechanism allows calling a function at most once during a
+ * certain period:
+ *
+ * - When a function call is made, it enters a 'cooldown' phase, in which any
+ * attempt to call the function is buffered until the cooldown phase ends.
+ * - At most 1 function call can be buffered during the cooldown phase, and the
+ * latest one in this phase will be considered at its end.
+ * - When a cooldown phase ends, any buffered function call will be performed
+ * and another cooldown phase will follow up.
+ *
+ * This throttle version has the following interesting properties:
+ *
+ * - cancellable: it allows removing a buffered function call during the
+ * cooldown phase, but it keeps the cooldown phase running.
+ * - clearable: it allows to clear the internal clock of the throttled function,
+ * so that any cooldown phase is immediately ending.
+ *
+ * @param {Object} params
+ * @param {integer} params.duration a duration for the throttled behaviour,
+ * in milli-seconds.
+ * @param {function} params.func the function to throttle
+ * @returns {function} the cancellable and clearable throttle version of the
+ * provided function in argument.
+ */
+var CCThrottleFunction = function (params) {
+ var duration = params.duration;
+ var func = params.func;
+
+ var throttleFunctionObject = new CCThrottleFunctionObject({
+ duration: duration,
+ func: func,
+ });
+
+ var callable = function () {
+ return throttleFunctionObject.do.apply(throttleFunctionObject, arguments);
+ };
+ callable.cancel = function () {
+ throttleFunctionObject.cancel();
+ };
+ callable.clear = function () {
+ throttleFunctionObject.clear();
+ };
+
+ return callable;
+};
+
+return CCThrottleFunction;
+
+});
diff --git a/addons/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js b/addons/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js
new file mode 100644
index 00000000000..fb1a3a91cd1
--- /dev/null
+++ b/addons/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js
@@ -0,0 +1,344 @@
+odoo.define('mail.model.ThreadTypingMixin', function (require) {
+"use strict";
+
+var CCThrottleFunction = require('mail.model.CCThrottleFunction');
+var Timer = require('mail.model.Timer');
+var Timers = require('mail.model.Timers');
+
+var core = require('web.core');
+
+var _t = core._t;
+
+/**
+ * Mixin for enabling the "is typing..." notification on a type of thread.
+ */
+var ThreadTypingMixin = {
+ // Default partner infos
+ _DEFAULT_TYPING_PARTNER_ID: '_default',
+ _DEFAULT_TYPING_PARTNER_NAME: 'Someone',
+
+ /**
+ * Initialize the internal data for typing feature on threads.
+ *
+ * Also listens on some internal events of the thread:
+ *
+ * - 'message_added': when a message is added, remove the author in the
+ * typing partners.
+ * - 'message_posted': when a message is posted, let the user have the
+ * possibility to immediately notify if he types something right away,
+ * instead of waiting for a throttle behaviour.
+ */
+ init: function () {
+ // Store the last "myself typing" status that has been sent to the
+ // server. This is useful in order to not notify the same typing
+ // status multiple times.
+ this._lastNotifiedMyselfTyping = false;
+
+ // Timer of current user that is typing a very long text. When the
+ // receivers do not receive any typing notification for a long time,
+ // they assume that the related partner is no longer typing
+ // something (e.g. they have closed the browser tab).
+ // This is a timer to let others know that we are still typing
+ // something, so that they do not assume we stopped typing
+ // something.
+ this._myselfLongTypingTimer = new Timer({
+ duration: 50*1000,
+ onTimeout: this._onMyselfLongTypingTimeout.bind(this),
+ });
+
+ // Timer of current user that was currently typing something, but
+ // there is no change on the input for several time. This is used
+ // in order to automatically notify other users that we have stopped
+ // typing something, due to making no changes on the composer for
+ // some time.
+ this._myselfTypingInactivityTimer = new Timer({
+ duration: 5*1000,
+ onTimeout: this._onMyselfTypingInactivityTimeout.bind(this),
+ });
+
+ // Timers of users currently typing in the thread. This is useful
+ // in order to automatically unregister typing users when we do not
+ // receive any typing notification after a long time. Timers are
+ // internally indexed by partnerID. The current user is ignored in
+ // this list of timers.
+ this._othersTypingTimers = new Timers({
+ duration: 60*1000,
+ onTimeout: this._onOthersTypingTimeout.bind(this),
+ });
+
+ // Clearable and cancellable throttled version of the
+ // `doNotifyMyselfTyping` method. (basically `notifyMyselfTyping`
+ // with slight pre- and post-processing)
+ // @see {mail.model.ResetableThrottleFunction}
+ // This is useful when the user posts a message and types something
+ // else: he must notify immediately that he is typing something,
+ // instead of waiting for the throttle internal timer.
+ this._throttleNotifyMyselfTyping = CCThrottleFunction({
+ duration: 2.5*1000,
+ func: this._onNotifyMyselfTyping.bind(this),
+ });
+
+ // This is used to track the order of registered partners typing
+ // something, in order to display the oldest typing partners.
+ this._typingPartnerIDs = [];
+
+ this.on('message_added', this, this._onTypingMessageAdded);
+ this.on('message_posted', this, this._onTypingMessagePosted);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the text to display when some partners are typing something on the
+ * thread:
+ *
+ * - single typing partner:
+ *
+ * A is typing...
+ *
+ * - two typing partners:
+ *
+ * A and B are typing...
+ *
+ * - three or more typing partners:
+ *
+ * A, B and more are typing...
+ *
+ * The choice of the members name for display is not random: it displays
+ * the user that have been typing for the longest time. Also, this function
+ * is hard-coded to display at most 2 partners. This limitation comes from
+ * how translation works in Odoo, for which unevaluated string cannot be
+ * translated.
+ *
+ * @returns {string} list of members that are typing something on the thread
+ * (excluding the current user).
+ */
+ getTypingMembersToText: function () {
+ var typingPartnerIDs = this._typingPartnerIDs;
+ var typingMembers = _.filter(this._members, function (member) {
+ return _.contains(typingPartnerIDs, member.id);
+ });
+ var sortedTypingMembers = _.sortBy(typingMembers, function (member) {
+ return _.indexOf(typingPartnerIDs, member.id);
+ });
+ var displayableTypingMembers = sortedTypingMembers.slice(0, 3);
+
+ if (displayableTypingMembers.length === 0) {
+ return '';
+ } else if (displayableTypingMembers.length === 1) {
+ return _.str.sprintf(_t("%s is typing..."), displayableTypingMembers[0].name);
+ } else if (displayableTypingMembers.length === 2) {
+ return _.str.sprintf(_t("%s and %s are typing..."),
+ displayableTypingMembers[0].name,
+ displayableTypingMembers[1].name);
+ } else {
+ return _.str.sprintf(_t("%s, %s and more are typing..."),
+ displayableTypingMembers[0].name,
+ displayableTypingMembers[1].name);
+ }
+ },
+ /**
+ * Threads with this mixin have the typing notification feature
+ *
+ * @returns {boolean}
+ */
+ hasTypingNotification: function () {
+ return true;
+ },
+ /**
+ * Register someone that is currently typing something in this thread.
+ * If this is the current user that is typing something, don't do anything
+ * (we do not have to display anything)
+ *
+ * This method is ignored if we try to register the current user.
+ *
+ * @param {Object} params
+ * @param {integer} params.partnerID ID of the partner linked to the user
+ * currently typing something on the thread.
+ */
+ registerTyping: function (params) {
+ if (this._isTypingMyselfInfo(params)) {
+ return;
+ }
+ this._othersTypingTimers.registerTimer({
+ timeoutCallbackArguments: [params.partnerID],
+ timerID: params.partnerID,
+ });
+ this._typingPartnerIDs.push(params.partnerID);
+ this._warnUpdatedTypingPartners();
+ },
+ /**
+ * This method must be called when the user starts or stops typing something
+ * in the composer of the thread.
+ *
+ * @param {Object} params
+ * @param {boolean} params.typing tell whether the current is typing or not.
+ */
+ setMyselfTyping: function (params) {
+ var typing = params.typing;
+ if (this._lastNotifiedMyselfTyping === typing) {
+ this._throttleNotifyMyselfTyping.cancel();
+ } else {
+ this._throttleNotifyMyselfTyping(params);
+ }
+
+ if (typing) {
+ this._myselfTypingInactivityTimer.reset();
+ } else {
+ this._myselfTypingInactivityTimer.clear();
+ }
+ },
+ /**
+ * Unregister someone from currently typing something in this thread.
+ *
+ * @param {Object} params
+ * @param {integer} params.partnerID ID of the partner related to the user
+ * that is currently typing something
+ */
+ unregisterTyping: function (params) {
+ var partnerID = params.partnerID;
+ this._othersTypingTimers.unregisterTimer({ timerID: partnerID });
+ this._typingPartnerIDs = _.reject(this._typingPartnerIDs, function (id) {
+ return id === partnerID;
+ });
+ this._warnUpdatedTypingPartners();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Tells whether the provided information on a partner is related to the
+ * current user or not.
+ *
+ * @abstract
+ * @private
+ * @param {Object} params
+ * @param {integer} params.partner ID of partner to check
+ */
+ _isTypingMyselfInfo: function (params) {
+ return false;
+ },
+ /**
+ * Notify to the server that the current user either starts or stops typing
+ * something.
+ *
+ * @abstract
+ * @private
+ * @param {Object} params
+ * @param {boolean} params.typing whether we are typing something or not
+ * @returns {$.Promise} resolved if the server is notified, rejected
+ * otherwise
+ */
+ _notifyMyselfTyping: function (params) {
+ return $.when();
+ },
+ /**
+ * Warn views that the list of users that are currently typing on this
+ * thread has been updated.
+ *
+ * @abstract
+ * @private
+ */
+ _warnUpdatedTypingPartners: function () {},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when current user is typing something for a long time. In order
+ * to not let other users assume that we are no longer typing something, we
+ * must notify again that we are typing something.
+ *
+ * @private
+ */
+ _onMyselfLongTypingTimeout: function () {
+ this._throttleNotifyMyselfTyping.clear();
+ this._throttleNotifyMyselfTyping({ typing: true });
+ },
+ /**
+ * Called when current user has something typed in the composer, but is
+ * inactive for some time. In this case, he automatically notifies that he
+ * is no longer typing something
+ *
+ * @private
+ */
+ _onMyselfTypingInactivityTimeout: function () {
+ this._throttleNotifyMyselfTyping.clear();
+ this._throttleNotifyMyselfTyping({ typing: false });
+ },
+ /**
+ * Called by throttled version of notify myself typing
+ *
+ * Notify to the server that the current user either starts or stops typing
+ * something. Remember last notified stuff from the server, and update
+ * related typing timers.
+ *
+ * @private
+ * @param {Object} params
+ * @param {boolean} params.typing whether we are typing something or not.
+ */
+ _onNotifyMyselfTyping: function (params) {
+ var typing = params.typing;
+ this._lastNotifiedMyselfTyping = typing;
+ this._notifyMyselfTyping(params);
+ if (typing) {
+ this._myselfLongTypingTimer.reset();
+ } else {
+ this._myselfLongTypingTimer.clear();
+ }
+ },
+ /**
+ * Called when current user do not receive a typing notification of someone
+ * else typing for a long time. In this case, we assume that this person is
+ * no longer typing something.
+ *
+ * @private
+ * @param {integer} partnerID partnerID of the person we assume he is no
+ * longer typing something.
+ */
+ _onOthersTypingTimeout: function (partnerID) {
+ this.unregisterTyping({ partnerID: partnerID });
+ },
+ /**
+ * Called when a new message is added to the thread
+ * On receiving a message from a typing partner, unregister this partner
+ * from typing partners (otherwise, it will still display it until timeout).
+ *
+ * @private
+ * @param {mail.model.AbstractMessage} message
+ */
+ _onTypingMessageAdded: function (message) {
+ var partnerID = message.hasAuthor() ?
+ message.getAuthorID() :
+ this._DEFAULT_TYPING_PARTNER_ID;
+ this.unregisterTyping({ partnerID: partnerID });
+ },
+ /**
+ * Called when current user has posted a message on this thread.
+ *
+ * The current user receives the possibility to immediately notify the
+ * other users if he is typing something else.
+ *
+ * Refresh the context for the current user to notify that he starts or
+ * stops typing something. In other words, when this function is called and
+ * then the current user types something, it immediately notifies the
+ * server as if it is the first time he is typing something.
+ *
+ * @private
+ */
+ _onTypingMessagePosted: function () {
+ this._lastNotifiedMyselfTyping = false;
+ this._throttleNotifyMyselfTyping.clear();
+ this._myselfLongTypingTimer.clear();
+ this._myselfTypingInactivityTimer.clear();
+ },
+};
+
+return ThreadTypingMixin;
+
+});
diff --git a/addons/mail/static/src/js/models/threads/thread_typing_mixin/timer.js b/addons/mail/static/src/js/models/threads/thread_typing_mixin/timer.js
new file mode 100644
index 00000000000..a3236fd1a16
--- /dev/null
+++ b/addons/mail/static/src/js/models/threads/thread_typing_mixin/timer.js
@@ -0,0 +1,69 @@
+odoo.define('mail.model.Timer', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+
+/**
+ * This class creates a timer which, when times out, calls a function.
+ */
+var Timer = Class.extend({
+
+ /**
+ * Instantiate a new timer. Note that the timer is not started on
+ * initialization (@see start method).
+ *
+ * @param {Object} params
+ * @param {integer} params.duration duration of timer before timeout in
+ * milli-seconds.
+ * @param {function} params.onTimeout function that is called when the
+ * timer times out.
+ */
+ init: function (params) {
+ this._duration = params.duration;
+ this._timeout = undefined;
+ this._timeoutCallback = params.onTimeout;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Clears the countdown of the timer.
+ */
+ clear: function () {
+ clearTimeout(this._timeout);
+ },
+ /**
+ * Resets the timer, i.e. resets its duration.
+ */
+ reset: function () {
+ this.clear();
+ this.start();
+ },
+ /**
+ * Starts the timer, i.e. after a certain duration, it times out and calls
+ * a function back.
+ */
+ start: function () {
+ this._timeout = setTimeout(this._onTimeout.bind(this), this._duration);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the timer times out, calls back a function on timeout.
+ *
+ * @private
+ */
+ _onTimeout: function () {
+ this._timeoutCallback();
+ },
+
+});
+
+return Timer;
+
+});
diff --git a/addons/mail/static/src/js/models/threads/thread_typing_mixin/timers.js b/addons/mail/static/src/js/models/threads/thread_typing_mixin/timers.js
new file mode 100644
index 00000000000..757945223f7
--- /dev/null
+++ b/addons/mail/static/src/js/models/threads/thread_typing_mixin/timers.js
@@ -0,0 +1,82 @@
+odoo.define('mail.model.Timers', function (require) {
+"use strict";
+
+var Timer = require('mail.model.Timer');
+
+var Class = require('web.Class');
+
+/**
+ * This class lists several timers that use a same callback and duration.
+ */
+var Timers = Class.extend({
+
+ /**
+ * Instantiate a new list of timers
+ *
+ * @param {Object} params
+ * @param {integer} params.duration duration of the underlying timers from
+ * start to timeout, in milli-seconds.
+ * @param {function} params.onTimeout a function to call back for underlying
+ * timers on timeout.
+ */
+ init: function (params) {
+ this._duration = params.duration;
+ this._timeoutCallback = params.onTimeout;
+ this._timers = {};
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Register a timer with ID `timerID` to start.
+ *
+ * - an already registered timer with this ID is reset.
+ * - (optional) can provide a list of arguments that is passed to the
+ * function callback when timer times out.
+ *
+ * @param {Object} params
+ * @param {Array} [params.timeoutCallbackArguments]
+ * @param {integer} params.timerID
+ */
+ registerTimer: function (params) {
+ var timerID = params.timerID;
+ if (this._timers[timerID]) {
+ this._timers[timerID].clear();
+ }
+ var timerParams = {
+ duration: this._duration,
+ onTimeout: this._timeoutCallback,
+ };
+ if ('timeoutCallbackArguments' in params) {
+ timerParams.onTimeout = this._timeoutCallback.bind.apply(
+ this._timeoutCallback,
+ [null].concat(params.timeoutCallbackArguments)
+ );
+ } else {
+ timerParams.onTimeout = this._timeoutCallback;
+ }
+ this._timers[timerID] = new Timer(timerParams);
+ this._timers[timerID].start();
+ },
+ /**
+ * Unregister a timer with ID `timerID`. The unregistered timer is aborted
+ * and will not time out.
+ *
+ * @param {Object} params
+ * @param {integer} params.timerID
+ */
+ unregisterTimer: function (params) {
+ var timerID = params.timerID;
+ if (this._timers[timerID]) {
+ this._timers[timerID].clear();
+ delete this._timers[timerID];
+ }
+ },
+
+});
+
+return Timers;
+
+});
diff --git a/addons/mail/static/src/js/services/mail_notification_manager.js b/addons/mail/static/src/js/services/mail_notification_manager.js
index 0e1fc4782d3..f6ab3e5a3ce 100644
--- a/addons/mail/static/src/js/services/mail_notification_manager.js
+++ b/addons/mail/static/src/js/services/mail_notification_manager.js
@@ -43,6 +43,24 @@ MailManager.include({
return notifications;
},
/**
+ * Called when receiving a notification on a channel (all members of a
+ * channel receive this notification)
+ *
+ * @private
+ * @param {Object} params
+ * @param {integer} [params.channelID]
+ * @param {Object} params.data
+ * @param {string} [params.data.info] if set, specify the type of data on
+ * this channel notification
+ */
+ _handleChannelNotification: function (params) {
+ if (params.data && params.data.info === 'typing_status') {
+ this._handleChannelTypingNotification(params.channelID, params.data);
+ } else {
+ this._handleChannelMessageNotification(params.data);
+ }
+ },
+ /**
* Called when a new or updated message is received on a channel
*
* @private
@@ -51,7 +69,7 @@ MailManager.include({
* (note that 'pending moderation' messages in moderated channels do not
* have the moderated channels in this array).
*/
- _handleChannelNotification: function (messageData) {
+ _handleChannelMessageNotification: function (messageData) {
var self = this;
var def;
var channelAlreadyInCache = true;
@@ -70,6 +88,36 @@ MailManager.include({
});
});
},
+ /**
+ * Called when someone starts or stops typing a message in a channel
+ *
+ * @private
+ * @param {integer} channelID
+ * @param {Object} typingData
+ * @param {boolean} typingData.is_typing
+ * @param {boolean} typingData.is_website_user
+ * @param {integer} typingData.partner_id
+ */
+ _handleChannelTypingNotification: function (channelID, typingData) {
+ var isWebsiteUser = typingData.is_website_user;
+ var partnerID = typingData.partner_id;
+ if (partnerID === session.partner_id && !isWebsiteUser) {
+ return; // ignore typing notification of myself
+ }
+ var channel = this.getChannel(channelID);
+ if (!channel) {
+ return;
+ }
+ var typingID = {
+ isWebsiteUser: typingData.is_website_user,
+ partnerID: typingData.partner_id,
+ };
+ if (typingData.is_typing) {
+ channel.registerTyping(typingID);
+ } else {
+ channel.unregisterTyping(typingID);
+ }
+ },
/**
* On message becoming a need action (pinned to inbox)
*
@@ -487,7 +535,11 @@ MailManager.include({
if (model === 'ir.needaction') {
self._handleNeedactionNotification(notif[1]);
} else if (model === 'mail.channel') {
- self._handleChannelNotification(notif[1]);
+ // new message in a channel
+ self._handleChannelNotification({
+ channelID: notif[0][2],
+ data: notif[1],
+ });
} else if (model === 'res.partner') {
self._handlePartnerNotification(notif[1]);
} else if (model === 'bus.presence') {
diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js
index 6b15a5a9a38..e2f6f0fb174 100644
--- a/addons/mail/static/src/js/services/mail_window_manager.js
+++ b/addons/mail/static/src/js/services/mail_window_manager.js
@@ -301,7 +301,8 @@ MailManager.include({
.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);
+ .on('update_dm_presence', this, this._onUpdateDmPresence)
+ .on('update_typing_partners', this, this._onTypingPartnersUpdated);
core.bus.on('resize', this, _.debounce(this._repositionThreadWindows.bind(this), 100));
},
@@ -628,6 +629,23 @@ MailManager.include({
this._updateThreadWindowsFromMessage(message, { keepBottom: true, passively: true });
},
/**
+ * @private
+ * @param {integer|string} threadID
+ */
+ _onTypingPartnersUpdated: function (threadID) {
+ var threadWindow = this._getThreadWindow(threadID);
+ if (!threadWindow) {
+ return;
+ }
+ var thread = this.getThread(threadID);
+ if (thread.isChannel()) {
+ // call getMentionpartnerSuggestions in order to correctly fetch members
+ thread.getMentionPartnerSuggestions().then(function () {
+ threadWindow.renderTypingNotificationBar();
+ });
+ }
+ },
+ /**
* Close the thread window when unsusbscribe from a channel.
*
* @private
diff --git a/addons/mail/static/src/js/thread_widget.js b/addons/mail/static/src/js/thread_widget.js
index c2d642d5dbe..2abd539f518 100644
--- a/addons/mail/static/src/js/thread_widget.js
+++ b/addons/mail/static/src/js/thread_widget.js
@@ -173,6 +173,12 @@ var ThreadWidget = Widget.extend({
dateFormat: time.getLangDatetimeFormat(),
}));
+ // must be after mail.widget.Thread rendering, so that there is the
+ // DOM element for the 'is typing' notification bar
+ if (thread.hasTypingNotification()) {
+ this.renderTypingNotificationBar(thread);
+ }
+
_.each(messages, function (message) {
var $message = self.$('.o_thread_message[data-message-id="'+ message.getID() +'"]');
$message.find('.o_mail_timestamp').data('date', message.getDate());
@@ -234,6 +240,28 @@ var ThreadWidget = Widget.extend({
return done;
},
/**
+ * Render the 'is typing...' text on the typing notification bar of the
+ * thread. This is called when there is a change in the list of users
+ * typing something on this thread.
+ *
+ * @param {mail.model.AbstractThread} thread with ThreadTypingMixin
+ */
+ renderTypingNotificationBar: function (thread) {
+ if (this._currentThreadID === thread.getID()) {
+ var shouldScrollToBottomAfterRendering = this.isAtBottom();
+
+ // typing notification bar rendering
+ var $typingBar = this.$('.o_thread_typing_notification_bar');
+ var text = thread.getTypingMembersToText();
+ $typingBar.toggleClass('o_hidden', !text); // hide if no text, because of padding
+ $typingBar.text(text);
+
+ if (shouldScrollToBottomAfterRendering) {
+ this.scrollToBottom();
+ }
+ }
+ },
+ /**
* Scroll to the bottom of the thread
*/
scrollToBottom: function () {
diff --git a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js
index ffed0e16a1f..8d02db587a8 100644
--- a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js
+++ b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js
@@ -249,6 +249,14 @@ var AbstractThreadWindow = Widget.extend({
QWeb.render('mail.AbstractThreadWindow.HeaderContent', options));
},
/**
+ * Render the 'is typing...' notification bar text on the thread in this
+ * thread window. This is called when there is a change in the list of users
+ * currently typing something on this thread.
+ */
+ renderTypingNotificationBar: function () {
+ this._threadWidget.renderTypingNotificationBar(this._thread);
+ },
+ /**
* Scroll to the bottom of the thread in the thread window
*/
scrollToBottom: function () {
diff --git a/addons/mail/static/src/scss/abstract_thread_window.scss b/addons/mail/static/src/scss/abstract_thread_window.scss
index 9f0293ac3c0..847eaa5a535 100644
--- a/addons/mail/static/src/scss/abstract_thread_window.scss
+++ b/addons/mail/static/src/scss/abstract_thread_window.scss
@@ -72,10 +72,14 @@ $o-mail-thread-window-zindex: $zindex-modal + 1 !default;
}
.o_mail_thread {
- flex: 1 1 auto;
+ flex: 1 1 100%;
overflow: auto;
-webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari)
+ .o_thread_typing_notification_bar {
+ @include o-transparent-background-color(0.75, $o-mail-thread-window-bg);
+ }
+
.o_thread_date_separator {
margin: 0px 0px 15px 0px;
.o_thread_date {
diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss
index e291e951118..227f3194937 100644
--- a/addons/mail/static/src/scss/thread.scss
+++ b/addons/mail/static/src/scss/thread.scss
@@ -3,10 +3,29 @@
text-align: center;
}
+ .o_mail_thread_content_asc, .o_mail_thread_content_desc {
+ display: flex;
+ flex-direction: column;
+ min-height: 100%;
+ }
+
.o_thread_bottom_free_space {
height: 15px;
}
+ .o_thread_typing_notification_free_space {
+ flex-grow: 1,
+ }
+
+ .o_thread_typing_notification_bar {
+ flex: 0, 0, 20px;
+ @include o-position-sticky($bottom: 0px);
+ @include o-transparent-background-color(0.75, white);
+ padding: 5px;
+ text-align: center;
+ color: gray('600');
+ }
+
.o_thread_tooltip_container {
display: inline;
position: relative;
diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml
index 9b81e5e8d28..2ec6826b51b 100644
--- a/addons/mail/static/src/xml/thread.xml
+++ b/addons/mail/static/src/xml/thread.xml
@@ -175,6 +175,7 @@
<div t-att-class="options.withPhantomJS ? '' : '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-if="thread.hasTypingNotification()" t-call="mail.widget.Thread.TypingNotificationBar"/>
<t t-call="mail.widget.Thread.Messages"/>
<t t-if="options.displayLoadMore" t-call="mail.widget.Thread.LoadMore"/>
</div>
@@ -206,10 +207,20 @@
<t t-if="options.displayBottomThreadFreeSpace">
<div class="o_thread_bottom_free_space"/>
</t>
+ <t t-if="thread.hasTypingNotification()" t-call="mail.widget.Thread.TypingNotificationBar"/>
</div>
</t>
<!--
+ @param {mail.model.AbstractThread} thread
+ -->
+ <t t-name="mail.widget.Thread.TypingNotificationBar">
+ <div class="o_thread_typing_notification_free_space" t-if="options.displayOrder === ORDER.DESC"/>
+ <div class="o_thread_typing_notification_bar"/>
+ <div class="o_thread_typing_notification_free_space" t-if="options.displayOrder === ORDER.ASC"/>
+ </t>
+
+ <!--
@param {mail.model.AbstractMessage[]} messages messages are ordered based
on desired display order
-->
diff --git a/addons/mail/static/tests/discuss_typing_notification_tests.js b/addons/mail/static/tests/discuss_typing_notification_tests.js
new file mode 100644
index 00000000000..0009c3d799b
--- /dev/null
+++ b/addons/mail/static/tests/discuss_typing_notification_tests.js
@@ -0,0 +1,604 @@
+odoo.define('mail.discuss_typing_notification_test', function (require) {
+"use strict";
+
+var Timer = require('mail.model.Timer');
+var CCThrottleFunctionObject = require('mail.model.CCThrottleFunctionObject');
+var mailTestUtils = require('mail.testUtils');
+
+var createDiscuss = mailTestUtils.createDiscuss;
+
+/**
+ * This is the test suite related to the feature 'is typing...' on the Discuss
+ * App.
+ *
+ * Untested aspects of the 'is typing' feature:
+ *
+ * - timeout on receiver's end, if someone is not typing something for some
+ * time (the user becomes unregistered in the list of typing partners).
+ * - is typing shown on thread windows
+ * - show that it works on DM chat and livechat in the backend too
+ */
+QUnit.module('mail', {}, function () {
+QUnit.module('Discuss (Typing Notifications)', {
+ beforeEach: function () {
+ var self = this;
+
+ // patch _.debounce and _.throttle to be fast and synchronous
+ this.underscoreDebounce = _.debounce;
+ this.underscoreThrottle = _.throttle;
+ _.debounce = _.identity;
+ _.throttle = _.identity;
+
+ // patch Timer and CCThrottleFunction so that there are no
+ // timers at all in the Thread that have the Thread Typing Mixin.
+ this.patch = function () {
+
+ self.ORIGINAL_TIMER_CLEAR = Timer.prototype.clear;
+ self.ORIGINAL_TIMER_RESET = Timer.prototype.reset;
+ self.ORIGINAL_TIMER_START = Timer.prototype.start;
+ Timer.prototype.clear = function () {};
+ Timer.prototype.reset = function () {};
+ Timer.prototype.start = function () { this._onTimeout(); };
+
+ self.ORIGINAL_CCTFO_DO = CCThrottleFunctionObject.prototype.do;
+ CCThrottleFunctionObject.prototype.do = function () { this._func.apply(this, arguments); };
+ };
+ this.unpatch = function () {
+ Timer.prototype.clear = self.ORIGINAL_TIMER_CLEAR;
+ Timer.prototype.reset = self.ORIGINAL_TIMER_RESET;
+ Timer.prototype.start = self.ORIGINAL_TIMER_START;
+
+ CCThrottleFunctionObject.prototype.do = self.ORIGINAL_CCTFO_DO;
+ };
+ this.patch();
+
+ // Frequently used data for tests
+ this.generalChannelID = 1;
+ this.myName = "Myself";
+ this.myPartnerID = 30;
+
+ this.data = {
+ initMessaging: {
+ channel_slots: {
+ channel_channel: [{
+ id: this.generalChannelID,
+ channel_type: "channel",
+ name: "general",
+ }],
+ },
+ },
+ 'mail.message': {
+ fields: {
+ body: {
+ string: "Contents",
+ type: 'html',
+ },
+ author_id: {
+ string: "Author",
+ relation: 'res.partner',
+ },
+ channel_ids: {
+ string: "Channels",
+ type: 'many2many',
+ relation: 'mail.channel',
+ },
+ starred: {
+ string: "Starred",
+ type: 'boolean',
+ },
+ needaction: {
+ string: "Need Action",
+ type: 'boolean',
+ },
+ starred_partner_ids: {
+ string: "partner ids",
+ type: 'integer',
+ },
+ model: {
+ string: "Related Document model",
+ type: 'char',
+ },
+ res_id: {
+ string: "Related Document ID",
+ type: 'integer',
+ },
+ need_moderation: {
+ string: "Need moderation",
+ type: 'boolean',
+ },
+ },
+ // There must be at least one message in the thread in order to
+ // display the "is typing..." notification text, so this is just
+ // a message to pass this requirement.
+ records: [{
+ author_id: [this.myPartnerID, this.myName],
+ body: "<p>first message</p>",
+ channel_ids: [this.generalChannelID],
+ id: 100,
+ model: 'mail.channel',
+ res_id: this.generalChannelID,
+ }],
+ },
+ };
+ this.services = mailTestUtils.getMailServices();
+
+ /**
+ * Simulate that someone typing something (or stops typing)
+ *
+ * @param {Object} params
+ * @param {integer} params.channelID
+ * @param {boolean} params.isTyping
+ * @param {integer} params.partnerID
+ * @param {Widget} params.widget a widget that can call the bus_service
+ */
+ this.simulateIsTyping = function (params) {
+ var typingData = {
+ info: 'typing_status',
+ partner_id: params.partnerID,
+ is_typing: params.isTyping,
+ };
+ var notification = [[false, 'mail.channel', params.channelID], typingData];
+ params.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ };
+
+ },
+ afterEach: function () {
+ // unpatch _.debounce and _.throttle
+ _.debounce = this.underscoreDebounce;
+ _.throttle = this.underscoreThrottle;
+
+ // unpatch Timer and CCThrottleFunction to re-enable timers
+ // with the Thread Typing Mixin
+ this.unpatch();
+ }
+});
+
+QUnit.test('notify is typing', function (assert) {
+ assert.expect(11);
+ var done = assert.async();
+
+ var self = this;
+ // this is to track the step that we are testing in the `notify_typing`
+ // mocked RPC
+ var step = 1;
+
+ createDiscuss({
+ id: 1,
+ context: {},
+ params: {},
+ data: this.data,
+ services: this.services,
+ mockRPC: function (route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(args.method);
+ var channelID = args.args[0];
+ if (step === 1) {
+ // user notify that he is typing
+ assert.strictEqual(channelID, self.generalChannelID,
+ "should send a typing notification on general channel");
+ assert.strictEqual(args.kwargs.is_typing, true,
+ "should send a currently typing notification");
+ step++;
+ } else if (step === 2) {
+ assert.strictEqual(channelID, self.generalChannelID,
+ "should send a typing notification on general channel");
+ assert.strictEqual(args.kwargs.is_typing, false,
+ "should send a stopped typing notification");
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ session: { partner_id: this.myPartnerID },
+ }).then(function (discuss) {
+
+ var $general = discuss.$('.o_mail_discuss_sidebar')
+ .find('.o_mail_discuss_item[data-thread-id=1]');
+ assert.strictEqual($general.length, 1,
+ "should have the channel item with id 1");
+ assert.strictEqual($general.attr('title'), 'general',
+ "should have the title 'general'");
+
+ // click on general
+ $general.click();
+
+ var $input = discuss.$('textarea.o_composer_text_field').first();
+ assert.ok($input.length, "should display a composer input");
+
+ // STEP 1: current user is typing something
+ step = 1;
+ $input.focus();
+ $input.val("1");
+ $input.trigger('input');
+ assert.verifySteps(['notify_typing']);
+
+ // STEP 2: current user clears input
+ step = 2;
+ $input.val("");
+ $input.trigger('input');
+ assert.verifySteps(['notify_typing', 'notify_typing']);
+
+ discuss.destroy();
+ done();
+ });
+});
+
+QUnit.test('receive is typing notification', function (assert) {
+ assert.expect(7);
+ var done = assert.async();
+
+ var self = this;
+ var fetchListenersDef = $.Deferred();
+
+ createDiscuss({
+ id: 1,
+ context: {},
+ params: {},
+ data: this.data,
+ services: this.services,
+ mockRPC: function (route, args) {
+ // when receiving an 'is typing' notification, fetch the list of
+ // members of this channel if we haven't done yet.
+ if (args.method === 'channel_fetch_listeners') {
+ fetchListenersDef.resolve();
+ return $.when([
+ { id: self.myPartnerID, name: self.myName },
+ { id: 42, name: "Someone" },
+ ]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ session: { partner_id: this.myPartnerID },
+ }).then(function (discuss) {
+ var $general = discuss.$('.o_mail_discuss_sidebar')
+ .find('.o_mail_discuss_item[data-thread-id=1]');
+ assert.strictEqual($general.length, 1,
+ "should have the channel item with id 1");
+ assert.strictEqual($general.attr('title'), 'general',
+ "should have the title 'general'");
+
+ // click on general
+ $general.click();
+ assert.strictEqual(discuss.$('.o_thread_typing_notification_bar').length, 1,
+ "there should be a typing notification bar on the thread");
+ assert.ok(discuss.$('.o_thread_typing_notification_bar').hasClass('o_hidden'),
+ "the typing notification bar should be hidden by default");
+
+ self.simulateIsTyping({
+ channelID: self.generalChannelID,
+ isTyping: true,
+ partnerID: 42,
+ widget: discuss,
+ });
+
+ fetchListenersDef.then(function () {
+ assert.notOk(discuss.$('.o_thread_typing_notification_bar').hasClass('o_hidden'),
+ "the typing notification bar should be visible when someone is typing something");
+ assert.strictEqual(discuss.$('.o_thread_typing_notification_bar').text(),
+ "Someone is typing...",
+ "It should display that the other user is typing something");
+
+ self.simulateIsTyping({
+ channelID: self.generalChannelID,
+ isTyping: false,
+ partnerID: 42,
+ widget: discuss,
+ });
+ assert.ok(discuss.$('.o_thread_typing_notification_bar').hasClass('o_hidden'),
+ "the typing notification bar should be hidden when someone stops typing something");
+
+ discuss.destroy();
+ done();
+ });
+ });
+});
+
+QUnit.test('receive message of someone that was typing something', function (assert) {
+ assert.expect(4);
+ var done = assert.async();
+
+ var self = this;
+ var fetchListenersDef = $.Deferred();
+
+ createDiscuss({
+ id: 1,
+ context: {},
+ params: {},
+ data: this.data,
+ services: this.services,
+ mockRPC: function (route, args) {
+ // when receiving an 'is typing' notification, fetch the list of
+ // members of this channel if we haven't done yet.
+ if (args.method === 'channel_fetch_listeners') {
+ fetchListenersDef.resolve();
+ return $.when([
+ { id: self.myPartnerID, name: self.myName },
+ { id: 42, name: "Someone" },
+ ]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ session: { partner_id: this.myPartnerID },
+ }).then(function (discuss) {
+ // click on general channel
+ discuss.$('.o_mail_discuss_sidebar')
+ .find('.o_mail_discuss_item[data-thread-id=1]')
+ .click();
+
+ assert.ok(discuss.$('.o_thread_typing_notification_bar').hasClass('o_hidden'),
+ "the typing notification bar should be hidden by default");
+
+ self.simulateIsTyping({
+ channelID: self.generalChannelID,
+ isTyping: true,
+ partnerID: 42,
+ widget: discuss,
+ });
+
+ fetchListenersDef.then(function () {
+ assert.notOk(discuss.$('.o_thread_typing_notification_bar').hasClass('o_hidden'),
+ "the typing notification bar should be visible when someone is typing something");
+ assert.strictEqual(discuss.$('.o_thread_typing_notification_bar').text(),
+ "Someone is typing...",
+ "It should display that the other user is typing something");
+
+ // Simulate receive message from the person typing something
+ var messageData = {
+ author_id: [42, 'Someone'],
+ body: "<p>test</p>",
+ channel_ids: [self.generalChannelID],
+ id: 101,
+ model: 'mail.channel',
+ res_id: self.generalChannelID,
+ };
+ var notification = [[false, 'mail.channel', self.generalChannelID], messageData];
+ discuss.call('bus_service', 'trigger', 'notification', [notification]);
+
+ assert.ok(discuss.$('.o_thread_typing_notification_bar').hasClass('o_hidden'),
+ "the typing notification bar should be hidden when receiving message from someone that was typing some text");
+
+ discuss.destroy();
+ done();
+ });
+ });
+});
+
+QUnit.test('do not display myself as typing', function (assert) {
+ assert.expect(3);
+ var done = assert.async();
+
+ var self = this;
+ var fetchListenersDef = $.Deferred();
+
+ createDiscuss({
+ id: 1,
+ context: {},
+ params: {},
+ data: this.data,
+ services: this.services,
+ mockRPC: function (route, args) {
+ // when receiving an 'is typing' notification, fetch the list of
+ // members of this channel if we haven't done yet.
+ if (args.method === 'channel_fetch_listeners') {
+ fetchListenersDef.resolve();
+ return $.when([
+ { id: self.myPartnerID, name: self.myName },
+ ]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ session: { partner_id: this.myPartnerID },
+ }).then(function (discuss) {
+ // click on general
+ var $general = discuss.$('.o_mail_discuss_sidebar')
+ .find('.o_mail_discuss_item[data-thread-id=1]');
+ $general.click();
+ assert.strictEqual(discuss.$('.o_thread_typing_notification_bar').length, 1,
+ "there should be a typing notification bar on the thread");
+ assert.ok(discuss.$('.o_thread_typing_notification_bar').hasClass('o_hidden'),
+ "the typing notification bar should be hidden by default");
+
+ self.simulateIsTyping({
+ channelID: self.generalChannelID,
+ isTyping: true,
+ partnerID: self.myPartnerID,
+ widget: discuss,
+ });
+
+ fetchListenersDef.then(function () {
+ assert.ok(discuss.$('.o_thread_typing_notification_bar').hasClass('o_hidden'),
+ "the typing notification bar should still be hidden when I am typing something");
+
+ discuss.destroy();
+ done();
+ });
+ });
+});
+
+QUnit.test('several users typing something at the same time', function (assert) {
+ // The order to display several typing partners name is not random:
+ // it shows at most 2 typing partners, which are the typing partners that
+ // have been typing something for the longest time.
+ // @see mail.model.ThreadTypingMixin:getTypingMembersToText for the rule
+ // applied for displaying the typing notification text.
+ assert.expect(9);
+ var done = assert.async();
+
+ var self = this;
+ var fetchListenersDef = $.Deferred();
+
+ createDiscuss({
+ id: 1,
+ context: {},
+ params: {},
+ data: this.data,
+ services: this.services,
+ mockRPC: function (route, args) {
+ // when receiving an 'is typing' notification, fetch the list of
+ // members of this channel if we haven't done yet.
+ if (args.method === 'channel_fetch_listeners') {
+ fetchListenersDef.resolve();
+ return $.when([
+ { id: self.myPartnerID, name: self.myName },
+ { id: 42, name: "Someone" },
+ { id: 43, name: "Anonymous" },
+ { id: 44, name: "Shy Guy" },
+ ]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ session: { partner_id: this.myPartnerID },
+ }).then(function (discuss) {
+
+ var $general = discuss.$('.o_mail_discuss_sidebar')
+ .find('.o_mail_discuss_item[data-thread-id=1]');
+ assert.strictEqual($general.length, 1,
+ "should have the channel item with id 1");
+ assert.strictEqual($general.attr('title'), 'general',
+ "should have the title 'general'");
+
+ // click on general
+ $general.click();
+ assert.strictEqual(discuss.$('.o_thread_typing_notification_bar').length, 1,
+ "there should be a typing notification bar on the thread");
+ assert.ok(discuss.$('.o_thread_typing_notification_bar').hasClass('o_hidden'),
+ "the typing notification bar should be hidden by default");
+
+ self.simulateIsTyping({
+ channelID: self.generalChannelID,
+ isTyping: true,
+ partnerID: 42,
+ widget: discuss,
+ });
+
+ fetchListenersDef.then(function () {
+ assert.notOk(discuss.$('.o_thread_typing_notification_bar').hasClass('o_hidden'),
+ "the typing notification bar should be visible when someone is typing something");
+ assert.strictEqual(discuss.$('.o_thread_typing_notification_bar').text(),
+ "Someone is typing...",
+ "It should display that the other user is typing something");
+
+ self.simulateIsTyping({
+ channelID: self.generalChannelID,
+ isTyping: true,
+ partnerID: 43,
+ widget: discuss,
+ });
+ assert.strictEqual(discuss.$('.o_thread_typing_notification_bar').text(),
+ "Someone and Anonymous are typing...",
+ "It should display the two users that are typing something");
+
+ self.simulateIsTyping({
+ channelID: self.generalChannelID,
+ isTyping: true,
+ partnerID: 44,
+ widget: discuss,
+ });
+ assert.strictEqual(discuss.$('.o_thread_typing_notification_bar').text(),
+ "Someone, Anonymous and more are typing...",
+ "It should display that several users are typing something");
+
+ self.simulateIsTyping({
+ channelID: self.generalChannelID,
+ isTyping: false,
+ partnerID: 42,
+ widget: discuss,
+ });
+
+ assert.strictEqual(discuss.$('.o_thread_typing_notification_bar').text(),
+ "Anonymous and Shy Guy are typing...",
+ "It should omit Someone that is typing");
+
+ discuss.destroy();
+ done();
+ });
+
+ });
+});
+
+
+QUnit.test('long typing partner A and in-between short typing partner B', function (assert) {
+ // Let's suppose that A is typing a very long message. If B types a short
+ // and sends it, it should display that B is typing, remove B from display
+ // when receiving the message, but still keep A in the typing notification.
+ assert.expect(4);
+ var done = assert.async();
+
+ var self = this;
+ var fetchListenersDef = $.Deferred();
+
+ createDiscuss({
+ id: 1,
+ context: {},
+ params: {},
+ data: this.data,
+ services: this.services,
+ mockRPC: function (route, args) {
+ // when receiving an 'is typing' notification, fetch the list of
+ // members of this channel if we haven't done yet.
+ if (args.method === 'channel_fetch_listeners') {
+ fetchListenersDef.resolve();
+ return $.when([
+ { id: self.myPartnerID, name: self.myName },
+ { id: 42, name: "A" },
+ { id: 43, name: "B" },
+ ]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ session: { partner_id: this.myPartnerID },
+ }).then(function (discuss) {
+ // click on general channel
+ discuss.$('.o_mail_discuss_sidebar')
+ .find('.o_mail_discuss_item[data-thread-id=1]')
+ .click();
+
+ self.simulateIsTyping({
+ channelID: self.generalChannelID,
+ isTyping: true,
+ partnerID: 42,
+ widget: discuss,
+ });
+
+ fetchListenersDef.then(function () {
+ assert.notOk(discuss.$('.o_thread_typing_notification_bar').hasClass('o_hidden'),
+ "the typing notification bar should be visible when someone is typing something");
+ assert.strictEqual(discuss.$('.o_thread_typing_notification_bar').text(),
+ "A is typing...",
+ "It should display that A is typing something");
+
+ self.simulateIsTyping({
+ channelID: self.generalChannelID,
+ isTyping: true,
+ partnerID: 43,
+ widget: discuss,
+ });
+
+ // B comes after A in the display, because A is oldest typing partner.
+ assert.strictEqual(discuss.$('.o_thread_typing_notification_bar').text(),
+ "A and B are typing...",
+ "It should display that A and B are typing something");
+
+ // Simulate receive message from B
+ var messageData = {
+ author_id: [43, 'Someone'],
+ body: "<p>test</p>",
+ channel_ids: [self.generalChannelID],
+ id: 101,
+ model: 'mail.channel',
+ res_id: self.generalChannelID,
+ };
+ var notification = [[false, 'mail.channel', self.generalChannelID], messageData];
+ discuss.call('bus_service', 'trigger', 'notification', [notification]);
+
+ assert.strictEqual(discuss.$('.o_thread_typing_notification_bar').text(),
+ "A is typing...",
+ "It should display that A is still typing something");
+
+ discuss.destroy();
+ done();
+ });
+ });
+});
+
+});
+});
diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js
index cff4917465a..ecf5910a866 100644
--- a/addons/mail/static/tests/helpers/mock_server.js
+++ b/addons/mail/static/tests/helpers/mock_server.js
@@ -176,6 +176,9 @@ MockServer.include({
if (args.method === 'moderate') {
return $.when(this._mockModerate(args));
}
+ if (args.method === 'notify_typing') {
+ return $.when();
+ }
if (args.method === 'set_message_done') {
return $.when();
}
diff --git a/addons/mail/static/tests/models/cc_throttle_function_tests.js b/addons/mail/static/tests/models/cc_throttle_function_tests.js
new file mode 100644
index 00000000000..6f74dcfe8d2
--- /dev/null
+++ b/addons/mail/static/tests/models/cc_throttle_function_tests.js
@@ -0,0 +1,155 @@
+odoo.define('mail.model.CCThrottleFunctionTests', function (require) {
+"use strict";
+
+var CCThrottleFunction = require('mail.model.CCThrottleFunction');
+var CCThrottleFunctionObject = require('mail.model.CCThrottleFunctionObject');
+
+var testUtils = require('web.test_utils');
+var Widget = require('web.Widget');
+
+QUnit.module('mail', {
+ beforeEach: function () {
+
+ var self = this;
+ this.timeoutCDDef = $.Deferred();
+
+ this.patch = function () {
+ self.ORIGINAL_CCTFO_ON_CD_TIMEOUT = CCThrottleFunctionObject.prototype._onCooldownTimeout;
+ CCThrottleFunctionObject.prototype._onCooldownTimeout = function () {
+ self.timeoutCDDef.then(self.ORIGINAL_CCTFO_ON_CD_TIMEOUT.apply(this, arguments));
+ };
+ };
+ this.unpatch = function () {
+ CCThrottleFunctionObject.prototype._onCooldownTimeout = self.ORIGINAL_CCTFO_ON_CD_TIMEOUT;
+ };
+ this.patch();
+ },
+ afterEach: function () {
+ this.unpatch();
+ },
+}, function () {
+
+QUnit.module('model', {}, function () {
+QUnit.module('CC Throttle Function');
+
+QUnit.test('cancel()', function (assert) {
+ var done = assert.async();
+ assert.expect(3);
+
+ var self = this;
+ var def = $.Deferred();
+ var step = 1;
+
+ var widget = new Widget();
+ testUtils.addMockEnvironment(widget, {
+ mockRPC: function (route, args) {
+ if (args.method === '__rpc__') {
+ assert.step(args.method + args.args[0]);
+
+ if (step === 1) {
+ def.resolve();
+ }
+ else {
+ assert.notOk(true, "should not perform rpc more than once");
+ }
+ step++;
+
+ return $.when();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var func = function () {
+ widget._rpc({
+ method: '__rpc__',
+ args: arguments,
+ });
+ };
+
+ var cctFunc = CCThrottleFunction({
+ duration: 0,
+ func: func,
+ });
+
+ cctFunc(1);
+
+ def.then(function () {
+ assert.verifySteps(['__rpc__1'], "function should have been called once");
+ }).then(function () {
+ cctFunc(2);
+ cctFunc.cancel();
+ self.timeoutCDDef.resolve();
+ });
+ this.timeoutCDDef.then(function () {
+ assert.verifySteps(['__rpc__1'],
+ "function should still have been called once after 2nd function call cancelled");
+ done();
+ });
+});
+
+QUnit.test('clear()', function (assert) {
+ var done = assert.async();
+ assert.expect(5);
+
+ var self = this;
+ var def1 = $.Deferred();
+ var def2 = $.Deferred();
+ var step = 1;
+
+ var widget = new Widget();
+ testUtils.addMockEnvironment(widget, {
+ mockRPC: function (route, args) {
+ if (args.method === '__rpc__') {
+ assert.step(args.method + args.args[0]);
+
+ if (step === 1) {
+ def1.resolve();
+ } else if (step === 2) {
+ def2.resolve();
+ } else {
+ assert.notOk(true, "should not perform rpc more than twice");
+ }
+ step++;
+
+ return $.when();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var func = function () {
+ widget._rpc({
+ method: '__rpc__',
+ args: arguments,
+ });
+ };
+
+ var cctFunc = CCThrottleFunction({
+ duration: 1000*1000,
+ func: func,
+ });
+
+ cctFunc(1);
+
+ def1.then(function () {
+ assert.verifySteps(['__rpc__1'], "function should have been called once");
+ }).then(function () {
+ cctFunc(2);
+ }).then(function () {
+ assert.verifySteps(['__rpc__1'],
+ "function should still have been called once (due to long throttle)");
+ cctFunc.clear();
+ self.timeoutCDDef.resolve();
+ def2.resolve();
+ });
+ $.when(self.timeoutCDDef, def2).then(function () {
+ assert.verifySteps(['__rpc__1', '__rpc__2'],
+ "function should have been called twice after 'clear' (buffered 2nd function call)");
+ done();
+ });
+});
+
+});
+});
+});
diff --git a/addons/mail/static/tests/models/timer_tests.js b/addons/mail/static/tests/models/timer_tests.js
new file mode 100644
index 00000000000..535c5649ec9
--- /dev/null
+++ b/addons/mail/static/tests/models/timer_tests.js
@@ -0,0 +1,88 @@
+odoo.define('mail.model.TimerTests', function (require) {
+"use strict";
+
+var Timer = require('mail.model.Timer');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('model', {}, function () {
+QUnit.module('Timer');
+
+QUnit.test('start()', function (assert) {
+ var done = assert.async();
+ assert.expect(2);
+
+ var def = $.Deferred();
+ var func = function () {
+ def.resolve();
+ };
+ var t = new Timer({
+ duration: 0,
+ onTimeout: func,
+ });
+
+ assert.verifySteps([], "should not have called the function");
+
+ t.start();
+ def.then(function () {
+ assert.ok(true, "should have called the function");
+ done();
+ });
+});
+
+QUnit.test('clear()', function (assert) {
+ var done = assert.async();
+ assert.expect(1);
+
+ var func = function () {
+ assert.step('function_called');
+ };
+ var t = new Timer({
+ duration: 0,
+ onTimeout: func,
+ });
+
+ t.start();
+ t.clear();
+
+ // Called after timer timeout
+ setTimeout(function () {
+ assert.verifySteps([], "should not have called the function");
+ done();
+ }, 0);
+});
+
+QUnit.test('reset()', function (assert) {
+ var done = assert.async();
+ assert.expect(4);
+
+ var def = $.Deferred();
+ var func = function () {
+ assert.step('function_called');
+ };
+ var t = new Timer({
+ duration: 0,
+ onTimeout: func,
+ });
+
+ t.start();
+
+ // Called after 1st timer timeout
+ setTimeout(function () {
+ assert.verifySteps(['function_called'], "should have called the function once");
+ t.reset();
+ // Called after 2nd timer timeout
+ setTimeout(function () {
+ def.resolve();
+ }, 0);
+ }, 0);
+
+ def.then(function () {
+ assert.verifySteps(['function_called', 'function_called'],
+ "should have called the function twice (2nd time from reset)");
+ done();
+ });
+});
+
+});
+});
+});
diff --git a/addons/mail/static/tests/models/timers_tests.js b/addons/mail/static/tests/models/timers_tests.js
new file mode 100644
index 00000000000..eeaf2e2a8f6
--- /dev/null
+++ b/addons/mail/static/tests/models/timers_tests.js
@@ -0,0 +1,90 @@
+odoo.define('mail.model.TimersTests', function (require) {
+"use strict";
+
+var Timer = require('mail.model.Timer');
+var Timers = require('mail.model.Timers');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('model', {}, function () {
+QUnit.module('Timers');
+
+QUnit.test('register timers', function (assert) {
+ assert.expect(4);
+
+ // patch Timer so that there are immediate
+ this.ORIGINAL_TIMER_START = Timer.prototype.start;
+ Timer.prototype.start = function () { this._onTimeout(); };
+
+ var func = function () {
+ assert.step('function_called');
+ if (arguments.length) {
+ var args = Array.prototype.slice.call(arguments, 0);
+ assert.deepEqual(args, ['a', 'b'],
+ "should have called the function with some arguments");
+ }
+ };
+ var timers = new Timers({
+ duration: 0,
+ onTimeout: func,
+ });
+
+ timers.registerTimer({ timerID: 1 });
+ timers.registerTimer({
+ timerID: 2,
+ timeoutCallbackArguments: ['a', 'b']
+ });
+
+ assert.verifySteps(['function_called', 'function_called'],
+ "should have called the function twice");
+
+ // unpatch Timer
+ Timer.prototype.start = this.ORIGINAL_TIMER_START;
+});
+
+QUnit.test('register timers once per ID', function (assert) {
+ var done = assert.async();
+ assert.expect(2);
+
+ var func = function () {
+ assert.step('function_called');
+ };
+ var timers = new Timers({
+ duration: 0,
+ onTimeout: func,
+ });
+
+ timers.registerTimer({ timerID: 1 });
+ timers.registerTimer({ timerID: 1 });
+
+ // Called after timer(s) timeout
+ setTimeout(function () {
+ assert.verifySteps(['function_called'], "should have called the function once");
+ done();
+ }, 0);
+});
+
+QUnit.test('unregister timers', function (assert) {
+ var done = assert.async();
+ assert.expect(1);
+
+ var func = function () {
+ assert.step('function_called');
+ };
+ var timers = new Timers({
+ duration: 0,
+ onTimeout: func,
+ });
+
+ timers.registerTimer({ timerID: 1 });
+ timers.unregisterTimer({ timerID: 1 });
+
+ // Called after timer(s) timeout
+ setTimeout(function () {
+ assert.verifySteps([], "should not have called the function");
+ done();
+ }, 0);
+});
+
+});
+});
+});
diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml
index bda7c071a0c..fcae7d446cf 100644
--- a/addons/mail/views/mail_templates.xml
+++ b/addons/mail/views/mail_templates.xml
@@ -35,6 +35,11 @@
<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>
+ <!-- thread typing mixin -->
+ <script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js"></script>
+ <script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/timer.js"></script>
+ <script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/timers.js"></script>
+ <script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/cc_throttle_function.js"></script>
<!-- Widgets -->
<!-- thread windows -->
<script type="text/javascript" src="/mail/static/src/js/thread_windows/abstract_thread_window.js"></script>
@@ -103,6 +108,11 @@
<script type="text/javascript" src="/mail/static/tests/systray/systray_messaging_menu_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/systray/systray_messaging_menu_mail_failure_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/discuss_moderation_tests.js"></script>
+ <script type="text/javascript" src="/mail/static/tests/discuss_typing_notification_tests.js"></script>
+ <!-- models -->
+ <script type="text/javascript" src="/mail/static/tests/models/cc_throttle_function_tests.js"></script>
+ <script type="text/javascript" src="/mail/static/tests/models/timer_tests.js"></script>
+ <script type="text/javascript" src="/mail/static/tests/models/timers_tests.js"></script>
</xpath>
</template>
diff --git a/addons/web/static/src/scss/utils.scss b/addons/web/static/src/scss/utils.scss
index 812abded97a..3994e6e8228 100644
--- a/addons/web/static/src/scss/utils.scss
+++ b/addons/web/static/src/scss/utils.scss
@@ -25,6 +25,18 @@
}
// ------------------------------------------------------------------
+// Position sticky
+// ------------------------------------------------------------------
+@mixin o-position-sticky($top: auto, $right: auto, $bottom: auto, $left: auto) {
+ position: -webkit-sticky;
+ position: sticky;
+ top: $top;
+ left: $left;
+ bottom: $bottom;
+ right: $right;
+}
+
+// ------------------------------------------------------------------
// Text overflow
// ------------------------------------------------------------------
@mixin o-text-overflow($display: inline-block, $max-width: 100%) {
@@ -37,6 +49,14 @@
}
// ------------------------------------------------------------------
+// Transparent background color
+// ------------------------------------------------------------------
+@mixin o-transparent-background-color($alpha: 1, $color: white) {
+ // convert hex to rgba for applying opacity
+ background-color: rgba(red($color), green($color), blue($color), $alpha);
+}
+
+// ------------------------------------------------------------------
// Hovering effects
// ------------------------------------------------------------------
@mixin o-hover-opacity($default-opacity: 0.5, $hover-opacity: 1) {
diff --git a/addons/website_livechat/views/website_livechat.xml b/addons/website_livechat/views/website_livechat.xml
index f71721d92b5..adf4dfb668f 100644
--- a/addons/website_livechat/views/website_livechat.xml
+++ b/addons/website_livechat/views/website_livechat.xml
@@ -24,6 +24,11 @@
<!-- threads -->
<script type="text/javascript" src="/mail/static/src/js/models/threads/abstract_thread.js"></script>
<script type="text/javascript" src="/im_livechat/static/src/js/models/website_livechat.js"></script>
+ <!-- thread typing mixin -->
+ <script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js"></script>
+ <script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/timer.js"></script>
+ <script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/timers.js"></script>
+ <script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/cc_throttle_function.js"></script>
<!-- messages -->
<script type="text/javascript" src="/mail/static/src/js/models/messages/abstract_message.js"></script>
<script type="text/javascript" src="/im_livechat/static/src/js/models/website_livechat_message.js"></script>