From 5d1b3232fc02d3c0a7f10e6bb73e308596c3a5a5 Mon Sep 17 00:00:00 2001
From: Aaron Bohy <aab@odoo.com>
Date: Thu, 10 Dec 2015 16:33:46 +0100
Subject: [PATCH] [FIX] bus,mail: user presence

Before this rev., no notification was sent on the bus when the user presence
changed. Thus, the bullets displayed in Discuss were never updated and stayed
as they were on the initialilization of the chat_manager (on webclient launch).

This rev. makes the bus.presence notifications work, and handles them client
side.

Moreover, the disconnections detection is now performed at each poll (with a
maximum of 1 per minute), instead of randomly (1/100 chance) at each poll, as
it scales better than the former solution. We also added a cron that performs
this check every 5 minutes. It is needed to detect that the last connected user
just disconnected (useful for visitors in the website, trying to talk with a
livechat operator).

Also, the 'away' status is now handled client side, as it makes more sense
that way (being away at each poll, e.g. every 50seconds, during 10 minutes
doesn't mean that we didn't come back between two polls).

Finaly, in bus.js, CrossTabBus, we moved the code writing in/reading the local
storage after the tab registration as this code depends on the fact that the
tab is the master tab or not (and this is known only once the tab is
registered).

This rev. was necessary in stable because the livechat uses the user status to
detect if there is an operator available, and this was often inaccurate.
Moreover, it improves the user experience of the chat in the backend.
---
 addons/bus/__openerp__.py                    |  1 +
 addons/bus/bus_presence_cron.xml             | 13 ++++++
 addons/bus/controllers/main.py               |  1 +
 addons/bus/models/bus_presence.py            | 43 +++++++++-----------
 addons/bus/static/src/js/bus.js              | 27 ++++++++----
 addons/mail/static/src/js/chat_manager.js    | 12 ++++++
 addons/mail/static/src/js/client_action.js   | 10 +++--
 addons/mail/static/src/xml/client_action.xml |  2 +-
 8 files changed, 71 insertions(+), 38 deletions(-)
 create mode 100644 addons/bus/bus_presence_cron.xml

diff --git a/addons/bus/__openerp__.py b/addons/bus/__openerp__.py
index 2489f6a38f97..2a7185fdaf7b 100644
--- a/addons/bus/__openerp__.py
+++ b/addons/bus/__openerp__.py
@@ -6,6 +6,7 @@
     'description': "Instant Messaging Bus allow you to send messages to users, in live.",
     'depends': ['base', 'web'],
     'data': [
+        'bus_presence_cron.xml',
         'views/bus.xml',
         'security/ir.model.access.csv',
     ],
diff --git a/addons/bus/bus_presence_cron.xml b/addons/bus/bus_presence_cron.xml
new file mode 100644
index 000000000000..309c641e3f79
--- /dev/null
+++ b/addons/bus/bus_presence_cron.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding='UTF-8'?>
+<odoo>
+	<record model="ir.cron" id="account_asset_cron">
+        <field name="name">Check User Disconnections</field>
+        <field name="interval_number">5</field>
+        <field name="interval_type">minutes</field>
+        <field name="numbercall">-1</field>
+        <field name="doall" eval="False"/>
+        <field name="model" eval="'bus.presence'"/>
+        <field name="function" eval="'check_users_disconnection'"/>
+        <field name="args" eval="'()'" />
+    </record>
+</odoo>
diff --git a/addons/bus/controllers/main.py b/addons/bus/controllers/main.py
index 050eb6846259..d755cabe54fe 100644
--- a/addons/bus/controllers/main.py
+++ b/addons/bus/controllers/main.py
@@ -19,6 +19,7 @@ class BusController(openerp.http.Controller):
 
     # override to add channels
     def _poll(self, dbname, channels, last, options):
+        channels.append((request.db, 'bus.presence'))
         # update the user presence
         if request.session.uid and 'im_presence' in options:
             request.env['bus.presence'].update(options.get('im_presence'))
diff --git a/addons/bus/models/bus_presence.py b/addons/bus/models/bus_presence.py
index 445e9491a755..70aa9ef97269 100644
--- a/addons/bus/models/bus_presence.py
+++ b/addons/bus/models/bus_presence.py
@@ -1,6 +1,5 @@
 # -*- coding: utf-8 -*-
 import datetime
-import random
 import time
 
 from openerp import api, fields, models
@@ -11,6 +10,8 @@ from openerp.addons.bus.models.bus import TIMEOUT
 
 DISCONNECTION_TIMER = TIMEOUT + 5
 AWAY_TIMER = 600 # 10 minutes
+DISCONNECTIONS_CHECK_PERIOD = datetime.timedelta(minutes=1)  # check for user disconnections every minute
+last_disconnections_check = datetime.datetime.utcnow()
 
 
 class BusPresence(models.Model):
@@ -34,7 +35,7 @@ class BusPresence(models.Model):
     @api.model
     def update(self, user_presence=True):
         """ Register the given presence of the current user, and trigger a im_status change if necessary.
-            The status will not be written or sent if not necessary.
+            The status will not be sent if not necessary.
             :param user_presence : True, if the user (self._uid) is still detected using its browser.
             :type user_presence : boolean
         """
@@ -51,37 +52,31 @@ class BusPresence(models.Model):
             values['user_id'] = self._uid
             self.create(values)
         else:  # write the user presence if necessary
-            if user_presence:
-                values['last_presence'] = time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
-                values['status'] = 'online'
-            else:
-                threshold = datetime.datetime.now() - datetime.timedelta(seconds=AWAY_TIMER)
-                if datetime.datetime.strptime(presence.last_presence, DEFAULT_SERVER_DATETIME_FORMAT) < threshold:
-                    values['status'] = 'away'
+            values['status'] = 'online' if user_presence else 'away'
             send_notification = presence.status != values['status']
-            # write only if the last_poll is passed TIMEOUT, or if the status has changed
-            delta = datetime.datetime.utcnow() - datetime.datetime.strptime(presence.last_poll, DEFAULT_SERVER_DATETIME_FORMAT)
-            if delta > datetime.timedelta(seconds=TIMEOUT) or send_notification:
-                # Hide transaction serialization errors, which can be ignored, the presence update is not essential
-                with tools.mute_logger('openerp.sql_db'):
-                    presence.write(values)
+            # Hide transaction serialization errors, which can be ignored, the presence update is not essential
+            with tools.mute_logger('openerp.sql_db'):
+                presence.write(values)
         # avoid TransactionRollbackError
         self.env.cr.commit() # TODO : check if still necessary
         # notify if the status has changed
         if send_notification: # TODO : add user_id to the channel tuple to allow using user_watch in controller presence
-            self.env['bus.bus'].sendone((self._cr.dbname, 'bus.presence'), {'id': self._uid, 'im_status': values['status']})
-        # gc : disconnect the users having a too old last_poll. 1 on 100 chance to do it.
-        if random.random() < 0.01:
-            self.check_users_disconnection()
+            self.env['bus.bus'].sendone((self._cr.dbname, 'bus.presence'), {'id': self.env.user.partner_id.id, 'im_status': values['status']})
+        # check for disconnected users
+        self.check_users_disconnection()
         return True
 
     @api.model
     def check_users_disconnection(self):
         """ Disconnect the users having a too old last_poll """
-        limit_date = (datetime.datetime.utcnow() - datetime.timedelta(0, DISCONNECTION_TIMER)).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
-        presences = self.search([('last_poll', '<', limit_date), ('status', '!=', 'offline')])
-        presences.write({'status': 'offline'})
+        global last_disconnections_check
+        now = datetime.datetime.utcnow()
         notifications = []
-        for presence in presences:
-            notifications.append([(self._cr.dbname, 'bus.presence'), {'id': presence.user_id.id, 'im_status': presence.status}])
+        if (now - DISCONNECTIONS_CHECK_PERIOD) > last_disconnections_check:
+            last_disconnections_check = now
+            limit_date = (now - datetime.timedelta(0, DISCONNECTION_TIMER)).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
+            presences = self.search([('last_poll', '<', limit_date), ('status', '!=', 'offline')])
+            presences.write({'status': 'offline'})
+            for presence in presences:
+                notifications.append([(self._cr.dbname, 'bus.presence'), {'id': presence.user_id.partner_id.id, 'im_status': presence.status}])
         self.env['bus.bus'].sendmany(notifications)
diff --git a/addons/bus/static/src/js/bus.js b/addons/bus/static/src/js/bus.js
index 9373b7c7b1c0..29540b36cdff 100644
--- a/addons/bus/static/src/js/bus.js
+++ b/addons/bus/static/src/js/bus.js
@@ -7,11 +7,15 @@ var Widget = require('web.Widget');
 var bus = {};
 
 bus.ERROR_DELAY = 10000;
+bus.AWAY_TIMEOUT = 300000;  // 5 minutes
 
 bus.Bus = Widget.extend({
     init: function(){
+        var self = this;
         this._super();
-        this.options = {};
+        this.options = {
+            im_presence: true,
+        };
         this.activated = false;
         this.channels = [];
         this.last = 0;
@@ -21,9 +25,14 @@ bus.Bus = Widget.extend({
         // bus presence
         this.set("window_focus", true);
         this.on("change:window_focus", this, function () {
-            this.options.im_presence = this.get("window_focus");
+            clearTimeout(self.away_timeout);
             if (this.get("window_focus")) {
+                this.options.im_presence = true;
                 this.trigger('window_focus', this.is_master);
+            } else {
+                this.away_timeout = setTimeout(function () {
+                    self.options.im_presence = false;
+                }, bus.AWAY_TIMEOUT);
             }
         });
         $(window).on("focus", _.bind(this.window_focus, this));
@@ -120,13 +129,6 @@ var CrossTabBus = bus.Bus.extend({
         }
 
         on("storage", this.on_storage.bind(this));
-        if (this.is_master) {
-            setItem('bus.channels', this.channels);
-            setItem('bus.options', this.options);
-        } else {
-            this.channels = getItem('bus.channels', this.channels);
-            this.options = getItem('bus.options', this.options);
-        }
     },
     start_polling: function(){
         var self = this;
@@ -139,6 +141,13 @@ var CrossTabBus = bus.Bus.extend({
                 self.is_master = false;
                 self.stop_polling();
             });
+            if (this.is_master) {
+                setItem('bus.channels', this.channels);
+                setItem('bus.options', this.options);
+            } else {
+                this.channels = getItem('bus.channels', this.channels);
+                this.options = getItem('bus.options', this.options);
+            }
             return;  // start_polling will be called again on tab registration
         }
 
diff --git a/addons/mail/static/src/js/chat_manager.js b/addons/mail/static/src/js/chat_manager.js
index 909a4a0ff5b0..cd758790d64d 100644
--- a/addons/mail/static/src/js/chat_manager.js
+++ b/addons/mail/static/src/js/chat_manager.js
@@ -272,6 +272,7 @@ function make_channel (data, options) {
     if ('direct_partner' in data) {
         channel.type = "dm";
         channel.name = data.direct_partner[0].name;
+        channel.direct_partner_id = data.direct_partner[0].id;
         channel.status = data.direct_partner[0].im_status;
     }
     return channel;
@@ -399,6 +400,9 @@ function on_notification (notification) {
     } else if (model === 'res.partner') {
         // channel joined/left, message marked as read/(un)starred, chat open/closed
         on_partner_notification(notification[1]);
+    } else if (model === 'bus.presence') {
+        // update presence of users
+        on_presence_notification(notification[1]);
     }
 }
 
@@ -530,6 +534,14 @@ function on_chat_session_notification (chat_session) {
     }
 }
 
+function on_presence_notification (data) {
+    var dm = _.findWhere(channels, {direct_partner_id: data.id});
+    if (dm) {
+        dm.status = data.im_status;
+        chat_manager.bus.trigger('update_dm_presence', dm);
+    }
+}
+
 // Public interface
 //----------------------------------------------------------------------------------
 var chat_manager = {
diff --git a/addons/mail/static/src/js/client_action.js b/addons/mail/static/src/js/client_action.js
index 1a12ff36912f..3d97a35af0e8 100644
--- a/addons/mail/static/src/js/client_action.js
+++ b/addons/mail/static/src/js/client_action.js
@@ -52,8 +52,8 @@ var PartnerInviteDialog = Dialog.extend({
             width: '100%',
             allowClear: true,
             multiple: true,
-            formatResult: function(item){
-                var css_class = "fa-circle" + (item.im_status === 'online' ? "" : "-o");
+            formatResult: function(item) {
+                var css_class = (item.im_status === 'away' ? "fa-clock-o" : "fa-circle" + (item.im_status === 'online' ? "" : "-o"));
                 return $('<span class="fa">').addClass(css_class).text(item.text);
             },
             query: function (query) {
@@ -135,6 +135,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, {
         this.action = action;
         this.options = options || {};
         this.channels_scrolltop = {};
+        this.throttled_render_sidebar = _.throttle(this.render_sidebar.bind(this), 100, { leading: false });
     },
 
     willStart: function () {
@@ -213,9 +214,10 @@ var ChatAction = Widget.extend(ControlPanelMixin, {
                 chat_manager.bus.on('anyone_listening', self, function (channel, query) {
                     query.is_displayed = query.is_displayed || channel.id === self.channel.id;
                 });
-                chat_manager.bus.on('update_needaction', self, self.render_sidebar);
                 chat_manager.bus.on('unsubscribe_from_channel', self, self.render_sidebar);
-                chat_manager.bus.on('update_channel_unread_counter', self, self.render_sidebar);
+                chat_manager.bus.on('update_needaction', self, self.throttled_render_sidebar);
+                chat_manager.bus.on('update_channel_unread_counter', self, self.throttled_render_sidebar);
+                chat_manager.bus.on('update_dm_presence', self, self.throttled_render_sidebar);
             });
     },
 
diff --git a/addons/mail/static/src/xml/client_action.xml b/addons/mail/static/src/xml/client_action.xml
index 11b32ee0e8dc..bb29d8cca270 100644
--- a/addons/mail/static/src/xml/client_action.xml
+++ b/addons/mail/static/src/xml/client_action.xml
@@ -77,7 +77,7 @@
             <t t-set="counter" t-value="channel.needaction_counter"/>
             <div t-if="channel.type === channel_type" t-att-data-channel-id="channel.id"
                  t-attf-class="o_mail_chat_channel_item #{channel.unread_counter ? ' o_unread_message' : ''} #{(active_channel_id == channel.id) ? 'o_active': ''}">
-                <span><i t-if="display_status" t-att-class="'o_user_status fa ' + (channel.status == 'online' ? 'fa-circle' : 'fa-circle-o')"/></span>
+                <span><i t-if="display_status" t-att-class="'o_user_status fa ' + (channel.status == 'online' ? 'fa-circle' : (channel.status == 'away' ? 'fa-clock-o' : 'fa-circle-o'))" t-attf-title="#{channel.status}"/></span>
                 <span t-if="display_hash" class="o_mail_hash">#</span>
                 <t t-esc="channel.name"/>
                 <i t-if="channel.mass_mailing" class="fa fa-envelope-o"/>
-- 
GitLab