summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/js/models/threads
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/mail/static/src/js/models/threads
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/mail/static/src/js/models/threads')
-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
7 files changed, 765 insertions, 2 deletions
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;
+
+});