Permalink
Browse files

[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
  • Loading branch information...
alexkuhn committed Aug 11, 2018
1 parent 614f9f8 commit 5f1d32f3fb89f40740b25491caafb9a5710d555a
Showing with 2,068 additions and 8 deletions.
  1. +10 −0 addons/im_livechat/controllers/main.py
  2. +23 −0 addons/im_livechat/static/src/js/im_livechat.js
  3. +65 −1 addons/im_livechat/static/src/js/models/website_livechat.js
  4. +19 −0 addons/im_livechat/static/src/js/website_livechat_window.js
  5. +5 −0 addons/im_livechat/views/im_livechat_channel_templates.xml
  6. +21 −0 addons/mail/models/mail_channel.py
  7. +20 −0 addons/mail/static/src/js/composers/basic_composer.js
  8. +21 −1 addons/mail/static/src/js/discuss.js
  9. +10 −0 addons/mail/static/src/js/models/threads/abstract_thread.js
  10. +38 −1 addons/mail/static/src/js/models/threads/channel.js
  11. +41 −1 addons/mail/static/src/js/models/threads/livechat.js
  12. +181 −0 addons/mail/static/src/js/models/threads/thread_typing_mixin/cc_throttle_function.js
  13. +344 −0 addons/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js
  14. +69 −0 addons/mail/static/src/js/models/threads/thread_typing_mixin/timer.js
  15. +82 −0 addons/mail/static/src/js/models/threads/thread_typing_mixin/timers.js
  16. +54 −2 addons/mail/static/src/js/services/mail_notification_manager.js
  17. +19 −1 addons/mail/static/src/js/services/mail_window_manager.js
  18. +28 −0 addons/mail/static/src/js/thread_widget.js
  19. +8 −0 addons/mail/static/src/js/thread_windows/abstract_thread_window.js
  20. +5 −1 addons/mail/static/src/scss/abstract_thread_window.scss
  21. +19 −0 addons/mail/static/src/scss/thread.scss
  22. +11 −0 addons/mail/static/src/xml/thread.xml
  23. +604 −0 addons/mail/static/tests/discuss_typing_notification_tests.js
  24. +3 −0 addons/mail/static/tests/helpers/mock_server.js
  25. +155 −0 addons/mail/static/tests/models/cc_throttle_function_tests.js
  26. +88 −0 addons/mail/static/tests/models/timer_tests.js
  27. +90 −0 addons/mail/static/tests/models/timers_tests.js
  28. +10 −0 addons/mail/views/mail_templates.xml
  29. +20 −0 addons/web/static/src/scss/utils.scss
  30. +5 −0 addons/website_livechat/views/website_livechat.xml
@@ -115,3 +115,13 @@ def history_pages(self, pid, channel_uuid, page_history=None):
if channel:
channel._send_history_message(pid, page_history)
return True
@http.route('/im_livechat/notify_typing', type='json', auth='public')
def notify_typing(self, uuid, is_typing):
""" Broadcast the typing notification of the website user to other channel members
:param uuid: (string) the UUID of the livechat channel
:param is_typing: (boolean) tells whether the website user is typing or not.
"""
Channel = request.env['mail.channel']
channel = Channel.sudo().search([('uuid', '=', uuid)], limit=1)
channel.notify_typing(is_typing=is_typing, is_website_user=True)
@@ -48,6 +48,7 @@ var LivechatButton = Widget.extend({
'close_chat_window': '_onCloseChatWindow',
'post_message_chat_window': '_onPostMessageChatWindow',
'save_chat_window': '_onSaveChatWindow',
'updated_typing_partners': '_onUpdatedTypingPartners',
'updated_unread_counter': '_onUpdatedUnreadCounter',
},
events: {
@@ -122,6 +123,9 @@ var LivechatButton = Widget.extend({
serverURL: this._serverURL,
});
var message = new WebsiteLivechatMessage(this, data, options);
if (this._livechat) {
this._livechat.addMessage(message);
}
if (options && options.prepend) {
this._messages.unshift(message);
@@ -162,6 +166,17 @@ var LivechatButton = Widget.extend({
channel_uuid: this._livechat.getUUID(),
page_history: history,
});
} else if (notification[1].info === 'typing_status') {
var isWebsiteUser = notification[1].is_website_user;
if (isWebsiteUser) {
return; // do not handle typing status notification of myself
}
var partnerID = notification[1].partner_id;
if (notification[1].is_typing) {
this._livechat.registerTyping({ partnerID: partnerID });
} else {
this._livechat.unregisterTyping({ partnerID: partnerID });
}
} else { // normal message
this._addMessage(notification[1]);
this._renderMessages();
@@ -339,6 +354,14 @@ var LivechatButton = Widget.extend({
ev.stopPropagation();
utils.set_cookie('im_livechat_session', JSON.stringify(this._livechat.toData()), 60*60);
},
/**
* @private
* @param {OdooEvent} ev
*/
_onUpdatedTypingPartners: function (ev) {
ev.stopPropagation();
this._chatWindow.renderTypingNotificationBar();
},
/**
* @private
* @param {OdooEvent} ev
@@ -2,14 +2,15 @@ odoo.define('im_livechat.model.WebsiteLivechat', function (require) {
"use strict";
var AbstractThread = require('mail.model.AbstractThread');
var ThreadTypingMixin = require('mail.model.ThreadTypingMixin');
var session = require('web.session');
/**
* Thread model that represents a livechat on the website-side. This livechat
* is not linked to the mail service.
*/
var WebsiteLivechat = AbstractThread.extend({
var WebsiteLivechat = AbstractThread.extend(ThreadTypingMixin, {
/**
* @override
@@ -32,6 +33,7 @@ var WebsiteLivechat = AbstractThread.extend({
*/
init: function (params) {
this._super.apply(this, arguments);
ThreadTypingMixin.init.call(this, arguments);
this._members = [];
this._operatorPID = params.data.operator_pid;
@@ -46,6 +48,13 @@ var WebsiteLivechat = AbstractThread.extend({
} else {
this._folded = params.data.state === 'folded';
}
// Necessary for thread typing mixin to display is typing notification
// bar text (at least, for the operator in the members).
this._members.push({
id: this._operatorPID[0],
name: this._operatorPID[1]
});
},
//--------------------------------------------------------------------------
@@ -107,6 +116,39 @@ var WebsiteLivechat = AbstractThread.extend({
// Private
//--------------------------------------------------------------------------
/**
* @override {mail.model.ThreadTypingMixin}
* @private
* @param {Object} params
* @param {boolean} params.isWebsiteUser
* @returns {boolean}
*/
_isTypingMyselfInfo: function (params) {
return params.isWebsiteUser;
},
/**
* @override {mail.model.ThreadTypingMixin}
* @private
* @param {Object} params
* @param {boolean} params.typing
* @returns {$.Promise}
*/
_notifyMyselfTyping: function (params) {
return session.rpc('/im_livechat/notify_typing', {
uuid: this.getUUID(),
is_typing: params.typing,
}, { shadow: true });
},
/**
* Warn views that the list of users that are currently typing on this
* livechat has been updated.
*
* @override {mail.model.ThreadTypingMixin}
* @private
*/
_warnUpdatedTypingPartners: function () {
this.trigger_up('updated_typing_partners');
},
/**
* Warn that the unread counter has been updated on this livechat
*
@@ -116,6 +158,28 @@ var WebsiteLivechat = AbstractThread.extend({
_warnUpdatedUnreadCounter: function () {
this.trigger_up('updated_unread_counter');
},
//--------------------------------------------------------------------------
// Handler
//--------------------------------------------------------------------------
/**
* Override so that it only unregister typing operators.
*
* Note that in the frontend, there is no way to identify a message that is
* from the current user, because there is no partner ID in the session and
* a message with an author sets the partner ID of the author.
*
* @override {mail.model.ThreadTypingMixin}
* @private
* @param {mail.model.AbstractMessage} message
*/
_onTypingMessageAdded: function (message) {
var operatorID = this.getOperatorPID()[0];
if (message.hasAuthor() && message.getAuthorID() === operatorID) {
this.unregisterTyping({ partnerID: operatorID });
}
},
});
return WebsiteLivechat;
@@ -9,6 +9,9 @@ var AbstractThreadWindow = require('mail.AbstractThreadWindow');
* @see mail.AbstractThreadWindow for more information
*/
var LivechatWindow = AbstractThreadWindow.extend({
events: _.extend(AbstractThreadWindow.prototype.events, {
'input .o_composer_text_field': '_onInput',
}),
/**
* @override
* @param {im_livechat.im_livechat.LivechatButton} parent
@@ -63,6 +66,22 @@ var LivechatWindow = AbstractThreadWindow.extend({
this.trigger_up('post_message_chat_window', { messageData: messageData });
this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Called when the input in the composer changes
*
* @private
*/
_onInput: function () {
if (this.hasThread() && this._thread.hasTypingNotification()) {
var isTyping = this.$input.val().length > 0;
this._thread.setMyselfTyping({ typing: isTyping });
}
},
});
return LivechatWindow;
@@ -151,6 +151,11 @@
<!-- threads -->
<script type="text/javascript" src="/mail/static/src/js/models/threads/abstract_thread.js"></script>
<script type="text/javascript" src="/im_livechat/static/src/js/models/website_livechat.js"></script>
<!-- thread typing mixin -->
<script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/timer.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/timers.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/threads/thread_typing_mixin/cc_throttle_function.js"></script>
<!-- messages -->
<script type="text/javascript" src="/mail/static/src/js/models/messages/abstract_message.js"></script>
<script type="text/javascript" src="/im_livechat/static/src/js/models/website_livechat_message.js"></script>
@@ -727,6 +727,27 @@ def channel_invite(self, partner_ids):
# 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)
#------------------------------------------------------
@@ -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',
@@ -184,6 +185,14 @@ var BasicComposer = Widget.extend({
this.set('attachment_ids', state.attachments);
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.
*
@@ -561,6 +570,17 @@ var BasicComposer = Widget.extend({
_onEmojiImageFocus: function () {
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
@@ -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
@@ -1419,6 +1423,22 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
target: 'current'
});
},
/**
* @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
@@ -102,6 +102,16 @@ var AbstractThread = Class.extend(Mixins.EventDispatcherMixin, {
hasMessages: function () {
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.
*
Oops, something went wrong.

0 comments on commit 5f1d32f

Please sign in to comment.