diff options
| author | Alexandre Kühn <aku@odoo.com> | 2018-08-11 21:02:55 +0200 |
|---|---|---|
| committer | Alexandre Kühn <aku@odoo.com> | 2018-08-13 14:17:40 +0200 |
| commit | 5f1d32f3fb89f40740b25491caafb9a5710d555a (patch) | |
| tree | d94aab4d0a6fb36d60079391216b3dd4c89b23c7 /addons/mail | |
| parent | 614f9f84d7da281b0feacc39a612c19193f3a4d8 (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')
23 files changed, 1921 insertions, 7 deletions
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> |
