[IMP] bus: re-factoring of bus.bus (Longpolling and CrossTab)

The purpose of this change is to make the code clearer and testable.

In this change, the 'tab_manager' static object was merged with the bus
cross tab.

Cleaning was done to clearly define private and public functions as well
as handlers. The methods are documented and the constants are now defined
on the class. The bus use the service behavior with 'trigger_up'.

'bus.CrossTab' who extend 'bus.Longpolling' are instantiated by the
bus service.

The class is always instantiated with a parent, or root in the case of the website
(im_livechat), to use the ajax and localstorage services. So the behavior, perhaps
logger or redefined by the parents.
This commit is contained in:
Christophe Matthieu
2018-08-09 02:31:59 +02:00
parent 791c70b329
commit 6448420c5d
31 changed files with 1020 additions and 623 deletions
-485
View File
@@ -1,485 +0,0 @@
odoo.define('bus.bus', function (require) {
"use strict";
var local_storage = require('web.local_storage');
var session = require('web.session');
var Widget = require('web.Widget');
var bus = {};
var PARTNERS_PRESENCE_CHECK_PERIOD = 30000; // don't check presence more than once every 30s
var TAB_HEARTBEAT_PERIOD = 10000; // 10 seconds
var MASTER_TAB_HEARTBEAT_PERIOD = 1500; // 1.5 second
bus.ERROR_DELAY = 10000;
bus.Bus = Widget.extend({
/**
* @override
* @param {Object} params
* @param {Object} params.session the session to use to perform the poll RPC
* @param {string} params.pollRoute the longpolling route to use
*/
init: function (params) {
var self = this;
this._super();
this.pollRoute = params.pollRoute;
this.session = params.session;
this.options = {};
this.activated = false;
this.bus_id = _.uniqueId('bus');
this.channels = [];
this.last = 0;
this.stop = false;
this.is_master = true;
// bus presence
this.last_presence = new Date().getTime();
this.last_partners_presence_check = this.last_presence;
this.set("window_focus", true);
this.on("change:window_focus", this, function () {
if (this.get("window_focus")) {
this.trigger('window_focus', this.is_master);
}
});
$(window).on("focus." + this.bus_id, _.bind(this.focus_change, this, true));
$(window).on("blur." + this.bus_id, _.bind(this.focus_change, this, false));
$(window).on("unload." + this.bus_id, _.bind(this.focus_change, this, false));
_.each('click,keydown,keyup'.split(','), function (evtype) {
$(window).on(evtype + "." + self.bus_id, function () {
self.last_presence = new Date().getTime();
});
});
},
destroy: function () {
var self = this;
this.stop_polling();
$(window).off("focus." + this.bus_id);
$(window).off("blur." + this.bus_id);
$(window).off("unload." + this.bus_id);
_.each('click,keydown,keyup'.split(','), function (evtype) {
$(window).off(evtype + "." + self.bus_id);
});
},
start_polling: function () {
if (!this.activated) {
this.poll();
this.stop = false;
}
},
stop_polling: function () {
this.activated = false;
this.stop = true;
this.channels = [];
if (this._pollRpc) {
this._pollRpc.abort();
}
},
poll: function () {
var self = this;
self.activated = true;
var now = new Date().getTime();
var options = _.extend({}, this.options, {
bus_inactivity: now - this.get_last_presence(),
});
if (this.last_partners_presence_check + PARTNERS_PRESENCE_CHECK_PERIOD > now) {
options = _.omit(options, 'bus_presence_partner_ids');
} else {
this.last_partners_presence_check = now;
}
var data = {channels: self.channels, last: self.last, options: options};
// The backend has a maximum cycle time of 50 seconds so give +10 seconds
this._pollRpc = this.session.rpc(this.pollRoute, data, {shadow : true, timeout: 60000});
this._pollRpc.then(function (result) {
self._pollRpc = false;
self.on_notification(result);
if (!self.stop && self.activated) {
self.poll();
}
}, function (error, event) {
self._pollRpc = false;
// no error popup if request is interrupted or fails for any reason
event.preventDefault();
if (error && error.message === "XmlHttpRequestError abort") {
if (!self.stop && self.activated) {
self.poll();
}
} else {
// random delay to avoid massive longpolling
setTimeout(_.bind(self.poll, self), bus.ERROR_DELAY + (Math.floor((Math.random()*20)+1)*1000));
}
});
},
on_notification: function (notifications) {
var self = this;
var notifs = _.map(notifications, function (notif) {
if (notif.id > self.last) {
self.last = notif.id;
}
return [notif.channel, notif.message];
});
this.trigger("notification", notifs);
},
add_channel: function (channel) {
if (this.channels.indexOf(channel) === -1) {
this.channels.push(channel);
if (this._pollRpc) {
this._pollRpc.abort();
}
}
},
delete_channel: function (channel) {
var index = this.channels.indexOf(channel);
if (index !== -1) {
this.channels.splice(index, 1);
if (this._pollRpc) {
this._pollRpc.abort();
}
}
},
// bus presence : window focus/unfocus
focus_change: function (focus) {
this.set("window_focus", focus);
},
is_odoo_focused: function () {
return this.get("window_focus");
},
get_last_presence: function () {
return this.last_presence;
},
update_option: function (key, value) {
this.options[key] = value;
},
delete_option: function (key) {
if (_.contains(_.keys(this.options), key)) {
delete this.options[key];
}
},
});
/**
* CrossTabBus Widget
*
* Manage the communication before browser tab to allow only one tab polling for the others (performance improvement)
* When a tab is opened, and the start_polling method is called, the tab is signaling through the localStorage to the
* others. When a tab is closed, it signals its removing. If he was the master tab (the polling one), he choose another
* one in the list of open tabs. This one start polling for the other. When a notification is recieved from the poll, it
* is signaling through the localStorage too.
*
* localStorage used keys are:
*
* - bus.channels : shared public channel list to listen during the poll
* - bus.options : shared options
* - bus.notification : the received notifications from the last poll
* - bus.tab_list : list of opened tab ids
* - bus.tab_master : generated id of the master tab
*/
bus.CrossTabBus = bus.Bus.extend({
init: function () {
this._super.apply(this, arguments);
// used to prefix localStorage keys
this.sanitizedOrigin = this.session.origin.replace(/:\/{0,2}/g, '_');
this.is_master = false;
this.is_registered = false;
if (parseInt(this._getItem('last_ts', 0)) + 50000 < new Date().getTime()) {
this._setItem('last', -1);
}
on("storage", this.on_storage.bind(this));
},
start_polling: function () {
var self = this;
if (!this.is_registered) {
this.is_registered = true;
tab_manager.register_tab(function () {
self.is_master = true;
self.start_polling();
}, function () {
self.is_master = false;
self.stop_polling();
}, function () {
// Write last_presence in local storage if it has been updated since last heartbeat
var hb_period = this.is_master ? MASTER_TAB_HEARTBEAT_PERIOD : TAB_HEARTBEAT_PERIOD;
if (self.last_presence + hb_period > new Date().getTime()) {
self._setItem('last_presence', self.last_presence);
}
}, this._generateKey.bind(this));
if (this.is_master) {
this._setItem('channels', this.channels);
this._setItem('options', this.options);
} else {
this.channels = this._getItem('channels', this.channels);
this.options = this._getItem('options', this.options);
}
return; // start_polling will be called again on tab registration
}
if (this.is_master) {
this._super.apply(this, arguments);
}
},
on_notification: function (notifications) {
if (this.is_master) { // broadcast to other tabs
var last = this._getItem('last', -1);
var max_id = Math.max(last, 0);
var new_notifications = _.filter(notifications, function (notif) {
max_id = Math.max(max_id, notif.id);
return notif.id < 0 || notif.id > last;
});
this.last = max_id;
if (new_notifications.length) {
this._setItem('last', max_id);
this._setItem('last_ts', new Date().getTime());
this._setItem('notification', new_notifications);
this._super(new_notifications);
}
} else {
this._super.apply(this, arguments);
}
},
on_storage: function (e) {
// use the value of event to not read from
// localStorage (avoid race condition)
var value = e.newValue;
// notifications changed
if (e.key === this._generateKey('notification')) {
var notifs = JSON.parse(value);
this.on_notification(notifs);
}
// update channels
if (e.key === this._generateKey('channels')) {
this.channels = JSON.parse(value);
}
// update options
if (e.key === this._generateKey('options')) {
this.options = JSON.parse(value);
}
// update focus
if (e.key === this._generateKey('focus')) {
this.set('window_focus', JSON.parse(value));
}
},
add_channel: function () {
this._super.apply(this, arguments);
this._setItem('channels', this.channels);
},
delete_channel: function () {
this._super.apply(this, arguments);
this._setItem('channels', this.channels);
},
get_last_presence: function () {
return this._getItem('last_presence') || new Date().getTime();
},
update_option: function () {
this._super.apply(this, arguments);
this._setItem('options', this.options);
},
delete_option: function () {
this._super.apply(this, arguments);
this._setItem('options', this.options);
},
focus_change: function (focus) {
this._super.apply(this, arguments);
this._setItem('focus', focus);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Generates localStorage keys prefixed by bus. (the name of this addon),
* and the sanitized origin, to prevent keys from conflicting when several
* bus instances (polling different origins) co-exist.
*
* @private
* @param {string} key
* @returns key prefixed with the origin
*/
_generateKey: function (key) {
return 'bus.' + this.sanitizedOrigin + '.' + key;
},
/**
* @private
* @param {string} key
* @param {*} defaultValue
*/
_getItem: function (key, defaultValue) {
return getItem(this._generateKey(key), defaultValue);
},
/**
* @private
* @param {string} key
* @param {*} value
*/
_setItem: function (key, value) {
setItem(this._generateKey(key), value);
},
});
//utility functions
function on(type, listener) {
if (window.addEventListener) {
window.addEventListener(type, listener);
} else { //IE8
window.attachEvent('on' + type, listener);
}
}
function getItem(key, defaultValue) {
var val = local_storage.getItem(key);
return val ? JSON.parse(val) : defaultValue;
}
function setItem(key, value) {
local_storage.setItem(key, JSON.stringify(value));
}
var tab_manager = {
isMaster: false,
id: new Date().getTime() + ':' + (Math.random() * 1000000000 | 0),
register_tab: function (is_master_callback, is_no_longer_master, on_heartbeat_callback, generateKey) {
this.heartbeatKey = generateKey('heartbeat');
this.masterKey = generateKey('master');
this.peersKey = generateKey('peers');
this.is_master_callback = is_master_callback;
this.is_no_longer_master = is_no_longer_master || function () {};
this.on_heartbeat_callback = on_heartbeat_callback || function () {};
var peers = getItem(tab_manager.peersKey, {});
peers[tab_manager.id] = new Date().getTime();
setItem(tab_manager.peersKey, peers);
on('unload', function () {
// unload peer
var peers = getItem(tab_manager.peersKey, {});
delete peers[tab_manager.id];
setItem(tab_manager.peersKey, peers);
// unload master
if (tab_manager.isMaster) {
local_storage.removeItem(tab_manager.masterKey);
}
});
if (!local_storage.getItem(tab_manager.masterKey)) {
tab_manager.start_election();
}
on('storage', function (e) {
if (!e) { e = window.event;}
if (e.key !== tab_manager.masterKey) {
return;
}
if (e.newValue === null) { //master was unloaded
tab_manager.start_election();
}
});
tab_manager.heartbeat();
},
heartbeat: function () {
var current = new Date().getTime();
var heartbeatValue = local_storage.getItem(tab_manager.heartbeatKey) || 0;
var peers = getItem(tab_manager.peersKey, {});
if ((parseInt(heartbeatValue) + 5000) < current) {
// Heartbeat is out of date. Electing new master
tab_manager.start_election();
}
if (tab_manager.isMaster) {
//walk through all peers and kill old
var cleanedPeers = {};
for (var peerName in peers) {
if (peers[peerName] + 15000 > current) {
cleanedPeers[peerName] = peers[peerName];
}
}
if (!tab_manager.is_last_heartbeat_mine()) {
// someone else is also master...
// it should not happen, except in some race condition situation.
tab_manager.isMaster = false;
tab_manager.last_heartbeat = 0;
peers[tab_manager.id] = current;
setItem(tab_manager.peersKey, peers);
tab_manager.is_no_longer_master();
} else {
tab_manager.last_heartbeat = current;
local_storage.setItem(tab_manager.heartbeatKey, current);
setItem(tab_manager.peersKey, cleanedPeers);
}
} else {
//update own heartbeat
peers[tab_manager.id] = current;
setItem(tab_manager.peersKey, peers);
}
this.on_heartbeat_callback();
setTimeout(function () {
tab_manager.heartbeat();
}, tab_manager.isMaster ? MASTER_TAB_HEARTBEAT_PERIOD : TAB_HEARTBEAT_PERIOD);
},
is_last_heartbeat_mine: function () {
var heartbeatValue = local_storage.getItem(tab_manager.heartbeatKey) || 0;
return (parseInt(heartbeatValue) === tab_manager.last_heartbeat);
},
start_election: function () {
if (tab_manager.isMaster) {
return;
}
//check who's next
var peers = getItem(tab_manager.peersKey, {});
var now = new Date().getTime();
var newMaster;
for (var peerName in peers) {
//check for dead peers
if (peers[peerName] + 15000 < now) {
continue;
}
newMaster = peerName;
break;
}
if (newMaster === tab_manager.id) {
//we're next in queue. Electing as master
setItem(tab_manager.masterKey, tab_manager.id);
tab_manager.last_heartbeat = new Date().getTime();
setItem(tab_manager.heartbeatKey, tab_manager.last_heartbeat);
tab_manager.isMaster = true;
tab_manager.is_master_callback();
//removing master peer from queue
delete peers[newMaster];
setItem(tab_manager.peersKey, peers);
}
},
};
// bus singleton, depending of the browser :
// if supporting LocalStorage, there will be only one tab polling
var params = {
pollRoute: '/longpolling/poll',
session: session,
};
if (typeof Storage !== "undefined") {
bus.bus = new bus.CrossTabBus(params);
} else {
bus.bus = new bus.Bus(params);
}
return bus;
});
+355
View File
@@ -0,0 +1,355 @@
odoo.define('bus.CrossTab', function (require) {
"use strict";
var Longpolling = require('bus.Longpolling');
var session = require('web.session');
/**
* CrossTab
*
* This is an extension of the longpolling bus with browser cross-tab synchronization.
* It uses a Master/Slaves with Leader Election architecture:
* - a single tab handles longpolling.
* - tabs are synchronized by means of the local storage.
*
* localStorage used keys are:
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.channels : shared public channel list to listen during the poll
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.options : shared options
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.notification : the received notifications from the last poll
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.tab_list : list of opened tab ids
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.tab_master : generated id of the master tab
*
* trigger:
* - window_focus : when the window is focused
* - notification : when a notification is receive from the long polling
* - become_master : when this tab became the master
* - no_longer_master : when this tab is not longer the master (the user swith tab)
*/
var CrossTabBus = Longpolling.extend({
// constants
TAB_HEARTBEAT_PERIOD: 10000, // 10 seconds
MASTER_TAB_HEARTBEAT_PERIOD: 1500, // 1.5 seconds
HEARTBEAT_OUT_OF_DATE_PERIOD: 5000, // 5 seconds
HEARTBEAT_KILL_OLD_PERIOD: 15000, // 15 seconds
LOCAL_STORAGE_PREFIX: 'bus',
// properties
_isMasterTab: false,
_isRegistered: false,
/**
* @override
*/
init: function () {
this._super.apply(this, arguments);
var now = new Date().getTime();
var session = this.getSession();
// used to prefix localStorage keys
this._sanitizedOrigin = session.origin.replace(/:\/{0,2}/g, '_');
// prevents collisions between different tabs and in tests
this._id = _.uniqueId(this.LOCAL_STORAGE_PREFIX) + ':' + now;
if (this._callLocalStorage('getItem', 'last_ts', 0) + 50000 < now) {
this._callLocalStorage('removeItem', 'last');
}
this._lastNotificationID = this._callLocalStorage('getItem', 'last', -1);
this.call('local_storage', 'onStorage', this, this._onStorage);
},
destroy: function () {
this._super();
clearTimeout(this._heartbeatTimeout);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Share the bus channels with the others tab by the local storage
*
* @override
*/
addChannel: function () {
this._super.apply(this, arguments);
this._callLocalStorage('setItem', 'channels', this._channels);
},
/**
* Share the bus channels with the others tab by the local storage
*
* @override
*/
deleteChannel: function () {
this._super.apply(this, arguments);
this._callLocalStorage('setItem', 'channels', this._channels);
},
/**
* Tells whether this bus is related to the master tab.
*
* @returns {boolean}
*/
isMasterTab: function () {
return this._isMasterTab;
},
/**
* Use the local storage to share the long polling from the master tab.
*
* @override
*/
startPolling: function () {
if (this._isActive === null) {
this._heartbeat = this._heartbeat.bind(this);
}
if (!this._isRegistered) {
this._isRegistered = true;
var peers = this._callLocalStorage('getItem', 'peers', {});
peers[this._id] = new Date().getTime();
this._callLocalStorage('setItem', 'peers', peers);
$(window).on('unload.' + this._id, this._onUnload.bind(this));
if (!this._callLocalStorage('getItem', 'peers')) {
this._startElection();
}
this._heartbeat();
if (this._isMasterTab) {
this._callLocalStorage('setItem', 'channels', this._channels);
this._callLocalStorage('setItem', 'options', this._options);
} else {
this._channels = this._callLocalStorage('getItem', 'channels', this._channels);
this._options = this._callLocalStorage('getItem', 'options', this._options);
}
return; // startPolling will be called again on tab registration
}
if (this._isMasterTab) {
this._super.apply(this, arguments);
}
},
/**
* Share the option with the local storage
*
* @override
*/
updateOption: function () {
this._super.apply(this, arguments);
this._callLocalStorage('setItem', 'options', this._options);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Call local_storage service
*
* @private
* @param {string} method (getItem, setItem, removeItem, on)
* @param {string} key
* @param {any} param
* @returns service information
*/
_callLocalStorage: function (method, key, param) {
return this.call('local_storage', method, this._generateKey(key), param);
},
/**
* Generates localStorage keys prefixed by bus. (LOCAL_STORAGE_PREFIX = the name
* of this addon), and the sanitized origin, to prevent keys from
* conflicting when several bus instances (polling different origins)
* co-exist.
*
* @private
* @param {string} key
* @returns key prefixed with the origin
*/
_generateKey: function (key) {
return this.LOCAL_STORAGE_PREFIX + '.' + this._sanitizedOrigin + '.' + key;
},
/**
* @override
* @returns {integer} number of milliseconds since 1 January 1970 00:00:00
*/
_getLastPresence: function () {
return this._callLocalStorage('getItem', 'lastPresence') || this._super();
},
/**
* Check all the time (according to the constants) if the tab is the master tab and
* check if it is active. Use the local storage for this checks.
*
* @private
* @see _startElection method
*/
_heartbeat: function () {
var now = new Date().getTime();
var heartbeatValue = parseInt(this._callLocalStorage('getItem', 'heartbeat', 0));
var peers = this._callLocalStorage('getItem', 'peers', {});
if ((heartbeatValue + this.HEARTBEAT_OUT_OF_DATE_PERIOD) < now) {
// Heartbeat is out of date. Electing new master
this._startElection();
heartbeatValue = parseInt(this._callLocalStorage('getItem', 'heartbeat', 0));
}
if (this._isMasterTab) {
//walk through all peers and kill old
var cleanedPeers = {};
for (var peerName in peers) {
if (peers[peerName] + this.HEARTBEAT_KILL_OLD_PERIOD > now) {
cleanedPeers[peerName] = peers[peerName];
}
}
if (heartbeatValue !== this.lastHeartbeat) {
// someone else is also master...
// it should not happen, except in some race condition situation.
this._isMasterTab = false;
this.lastHeartbeat = 0;
peers[this._id] = now;
this._callLocalStorage('setItem', 'peers', peers);
this.stopPolling();
this.trigger('no_longer_master');
} else {
this.lastHeartbeat = now;
this._callLocalStorage('setItem', 'heartbeat', now);
this._callLocalStorage('setItem', 'peers', cleanedPeers);
}
} else {
//update own heartbeat
peers[this._id] = now;
this._callLocalStorage('setItem', 'peers', peers);
}
// Write lastPresence in local storage if it has been updated since last heartbeat
var hbPeriod = this._isMasterTab ? this.MASTER_TAB_HEARTBEAT_PERIOD : this.TAB_HEARTBEAT_PERIOD;
if (this._lastPresenceTime + hbPeriod > now) {
this._callLocalStorage('setItem', 'lastPresence', this._lastPresenceTime);
}
this._heartbeatTimeout = setTimeout(this._heartbeat, hbPeriod);
},
/**
* Check with the local storage if the current tab is the master tab.
* If this tab became the master, trigger 'become_master' event
*
* @private
*/
_startElection: function () {
if (this._isMasterTab) {
return;
}
//check who's next
var now = new Date().getTime();
var peers = this._callLocalStorage('getItem', 'peers', {});
var heartbeatKillOld = now - this.HEARTBEAT_KILL_OLD_PERIOD;
var newMaster;
for (var peerName in peers) {
//check for dead peers
if (peers[peerName] < heartbeatKillOld) {
continue;
}
newMaster = peerName;
break;
}
if (newMaster === this._id) {
//we're next in queue. Electing as master
this.lastHeartbeat = now;
this._callLocalStorage('setItem', 'heartbeat', this.lastHeartbeat);
this._isMasterTab = true;
this.startPolling();
this.trigger('become_master');
//removing master peer from queue
delete peers[newMaster];
this._callLocalStorage('setItem', 'peers', peers);
}
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @override
*/
_onFocusChange: function (params) {
this._super.apply(this, arguments);
this._callLocalStorage('setItem', 'focus', params.focus);
},
/**
* If it's the master tab, the notifications ares broadcasted to other tabs by the
* local storage.
*
* @override
*/
_onPoll: function (notifications) {
var notifs = this._super(notifications);
if (this._isMasterTab && notifs.length) {
this._callLocalStorage('setItem', 'last', this._lastNotificationID);
this._callLocalStorage('setItem', 'last_ts', new Date().getTime());
this._callLocalStorage('setItem', 'notification', notifs);
}
},
/**
* Handler when the local storage is updated
*
* @private
* @param {OdooEvent} event
* @param {string} event.key
* @param {string} event.newValue
*/
_onStorage: function (e) {
var value = JSON.parse(e.newValue);
var key = e.key;
if (this._isRegistered && key === this._generateKey('peers') && !value) {
//master was unloaded
this._startElection();
}
// last notification id changed
if (key === this._generateKey('last')) {
this._lastNotificationID = value;
}
// notifications changed
if (key === this._generateKey('notification')) {
if (!this._isMasterTab) {
this.trigger("notification", value);
}
return;
}
// update channels
if (key === this._generateKey('channels')) {
var channels = value;
_.each(_.difference(this._channels, channels), this.deleteChannel.bind(this));
_.each(_.difference(channels, this._channels), this.addChannel.bind(this));
}
// update options
if (key === this._generateKey('options')) {
this._options = value;
}
// update focus
if (key === this._generateKey('focus')) {
this._isOdooFocused = value;
this.trigger('window_focus', this._isOdooFocused);
}
},
/**
* Handler when unload the window
*
* @private
*/
_onUnload: function () {
// unload peer
var peers = this._callLocalStorage('getItem', 'peers', {});
delete peers[this._id];
this._callLocalStorage('setItem', 'peers', peers);
// unload master
if (this._isMasterTab) {
this._callLocalStorage('removeItem', 'peers');
}
},
});
return CrossTabBus;
});
+255
View File
@@ -0,0 +1,255 @@
odoo.define('bus.Longpolling', function (require) {
"use strict";
var Bus = require('web.Bus');
var ServicesMixin = require('web.ServicesMixin');
/**
* Event Longpolling bus used to bind events on the server long polling return
*
* trigger:
* - window_focus : when the window focus change (true for focused, false for blur)
* - notification : when a notification is receive from the long polling
*
* @class Longpolling
*/
var LongpollingBus = Bus.extend(ServicesMixin, {
// constants
PARTNERS_PRESENCE_CHECK_PERIOD: 30000, // don't check presence more than once every 30s
ERROR_RETRY_DELAY: 10000, // 10 seconds
POLL_ROUTE: '/longpolling/poll',
// properties
_isActive: null,
_lastNotificationID: 0,
_isOdooFocused: true,
_pollRetryTimeout: null,
/**
* @override
*/
init: function (parent, params) {
this._super.apply(this, arguments);
this._id = _.uniqueId('bus');
this._options = {};
this._channels = [];
// bus presence
this._lastPresenceTime = new Date().getTime();
this._lastPartnersPresenceCheck = this._lastPresenceTime;
$(window).on("focus." + this._id, this._onFocusChange.bind(this, {focus: true}));
$(window).on("blur." + this._id, this._onFocusChange.bind(this, {focus: false}));
$(window).on("unload." + this._id, this._onFocusChange.bind(this, {focus: false}));
$(window).on("click." + this._id, this._onPresence.bind(this));
$(window).on("keydown." + this._id, this._onPresence.bind(this));
$(window).on("keyup." + this._id, this._onPresence.bind(this));
},
/**
* @override
*/
destroy: function () {
this.stopPolling();
$(window).off("focus." + this._id);
$(window).off("blur." + this._id);
$(window).off("unload." + this._id);
$(window).off("click." + this._id);
$(window).off("keydown." + this._id);
$(window).off("keyup." + this._id);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Register a new channel to listen on the longpoll (ignore if already
* listening on this channel).
* Aborts a pending longpoll, in order to re-start another longpoll, so
* that we can immediately get notifications on newly registered channel.
*
* @param {string} channel
*/
addChannel: function (channel) {
if (this._channels.indexOf(channel) === -1) {
this._channels.push(channel);
if (this._pollRpc) {
this._pollRpc.abort();
} else {
this.startPolling();
}
}
},
/**
* Unregister a channel from listening on the longpoll.
*
* Aborts a pending longpoll, in order to re-start another longpoll, so
* that we immediately remove ourselves from listening on notifications
* on this channel.
*
* @param {string} channel
*/
deleteChannel: function (channel) {
var index = this._channels.indexOf(channel);
if (index !== -1) {
this._channels.splice(index, 1);
if (this._pollRpc) {
this._pollRpc.abort();
}
}
},
/**
* Tell whether odoo is focused or not
*
* @returns {boolean}
*/
isOdooFocused: function () {
return this._isOdooFocused;
},
/**
* Start a long polling, i.e. it continually opens a long poll
* connection as long as it is not stopped (@see `stopPolling`)
*/
startPolling: function () {
if (this._isActive === null) {
this._poll = this._poll.bind(this);
}
if (!this._isActive) {
this._isActive = true;
this._poll();
}
},
/**
* Stops any started long polling
*
* Aborts a pending longpoll so that we immediately remove ourselves
* from listening on notifications on this channel.
*/
stopPolling: function () {
this._isActive = false;
this._channels = [];
clearTimeout(this._pollRetryTimeout);
if (this._pollRpc) {
this._pollRpc.abort();
}
},
/**
* Add or update an option on the longpoll bus.
* Stored options are sent to the server whenever a poll is started.
*
* @param {string} key
* @param {any} value
*/
updateOption: function (key, value) {
this._options[key] = value;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* returns the last recorded presence
*
* @private
* @returns {integer} number of milliseconds since 1 January 1970 00:00:00
*/
_getLastPresence: function () {
return this._lastPresenceTime;
},
/**
* Continually start a poll:
*
* A poll is a connection that is kept open for a relatively long period
* (up to 1 minute). Local bus data are sent to the server each time a poll
* is initiated, and the server may return some "real-time" notifications
* about registered channels.
*
* A poll ends on timeout, on abort, on receiving some notifications, or on
* receiving an error. Another poll usually starts afterward, except if the
* poll is aborted or stopped (@see stopPolling).
*
* @private
*/
_poll: function () {
var self = this;
if (!this._isActive) {
return;
}
var now = new Date().getTime();
var options = _.extend({}, this._options, {
bus_inactivity: now - this._getLastPresence(),
});
if (this._lastPartnersPresenceCheck + this.PARTNERS_PRESENCE_CHECK_PERIOD > now) {
options = _.omit(options, 'bus_presence_partner_ids');
} else {
this._lastPartnersPresenceCheck = now;
}
var data = {channels: this._channels, last: this._lastNotificationID, options: options};
// The backend has a maximum cycle time of 50 seconds so give +10 seconds
this._pollRpc = this._rpc({route: this.POLL_ROUTE, params: data}, {shadow : true, timeout: 60000});
this._pollRpc.then(function (result) {
self._pollRpc = false;
self._onPoll(result);
self._poll();
}, function (error, ev) {
self._pollRpc = false;
// no error popup if request is interrupted or fails for any reason
ev.preventDefault();
if (error && error.message === "XmlHttpRequestError abort") {
self._poll();
} else {
// random delay to avoid massive longpolling
self._pollRetryTimeout = setTimeout(self._poll, self.ERROR_RETRY_DELAY + (Math.floor((Math.random()*20)+1)*1000));
}
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Handler when the focus of the window change.
* Trigger the 'window_focus' event.
*
* @private
* @param {Object} params
* @param {Boolean} params.focus
*/
_onFocusChange: function (params) {
this._isOdooFocused = params.focus;
if (params.focus) {
this._lastPresenceTime = new Date().getTime();
this.trigger('window_focus', this._isOdooFocused);
}
},
/**
* Handler when the long polling receive the new notifications
* Update the last notification id received.
* Triggered the 'notification' event with a list [channel, message] from notifications.
*
* @private
* @param {Object[]} notifications, Input notifications have an id, channel, message
* @returns {Array[]} Output arrays have notification's channel and message
*/
_onPoll: function (notifications) {
var self = this;
var notifs = _.map(notifications, function (notif) {
if (notif.id > self._lastNotificationID) {
self._lastNotificationID = notif.id;
}
return [notif.channel, notif.message];
});
this.trigger("notification", notifs);
return notifs;
},
/**
* Handler when they are an activity on the window (click, keydown, keyup)
* Update the last presence date.
*
* @private
*/
_onPresence: function () {
this._lastPresenceTime = new Date().getTime();
},
});
return LongpollingBus;
});
@@ -1,34 +1,27 @@
odoo.define('bus.BusService', function (require) {
"use strict";
var bus = require('bus.bus').bus;
var AbstractService = require('web.AbstractService');
var CrossTab = require('bus.CrossTab');
var core = require('web.core');
var ServicesMixin = require('web.ServicesMixin');
var BusService = CrossTab.extend(ServicesMixin, {
dependencies : ['local_storage'],
// properties
_audio: null,
var BusService = AbstractService.extend({
/**
* @override
* This method is necessary in order for this Class to be used to instantiate services
*
* @abstract
*/
start: function () {
this._super.apply(this, arguments);
this.bus = bus;
this._audio = null;
},
start: function () {},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Get the bus
*
* @return {web.Bus} the longpoll bus
*/
getBus: function () {
return this.bus;
},
/**
* Send a notification, and notify once per browser's tab
*
@@ -37,16 +30,25 @@ var BusService = AbstractService.extend({
*/
sendNotification: function (title, content) {
if (window.Notification && Notification.permission === "granted") {
if (this.bus.is_master) {
if (this.isMasterTab()) {
this._sendNativeNotification(title, content);
}
} else {
this.do_notify(title, content);
if (this.bus.is_master) {
if (this.isMasterTab()) {
this._beep();
}
}
},
/**
* Register listeners on notifications received on this bus service
*
* @param {Object} receiver
* @param {function} func
*/
onNotification: function () {
this.on.apply(this, ["notification"].concat(Array.prototype.slice.call(arguments)));
},
//--------------------------------------------------------------------------
// Private
+163
View File
@@ -0,0 +1,163 @@
odoo.define('web.bus_tests', function (require) {
"use strict";
var BusService = require('bus.BusService');
var AbstractStorageService = require('web.AbstractStorageService');
var RamStorage = require('web.RamStorage');
var testUtils = require('web.test_utils');
var Widget = require('web.Widget');
var LocalStorageServiceMock;
BusService = BusService.extend({
TAB_HEARTBEAT_PERIOD: 10,
MASTER_TAB_HEARTBEAT_PERIOD: 1,
});
QUnit.module('Bus', {
beforeEach: function () {
LocalStorageServiceMock = AbstractStorageService.extend({storage: new RamStorage()});
},
}, function () {
QUnit.test('notifications received from the longpolling channel', function (assert) {
assert.expect(6);
var pollDeferred = $.Deferred();
var parent = new Widget();
testUtils.addMockEnvironment(parent, {
data: {},
services: {
bus_service: BusService,
local_storage: LocalStorageServiceMock,
},
mockRPC: function (route, args) {
if (route === '/longpolling/poll') {
assert.step([route, args.channels.join(',')]);
pollDeferred = $.Deferred();
pollDeferred.abort = (function () {
this.reject({message: "XmlHttpRequestError abort"}, $.Event());
}).bind(pollDeferred);
return pollDeferred;
}
return this._super.apply(this, arguments);
}
});
var widget = new Widget(parent);
widget.appendTo($('#qunit-fixture'));
widget.call('bus_service', 'onNotification', this, function (notifications) {
assert.step(['notification', notifications]);
});
widget.call('bus_service', 'addChannel', 'lambda');
pollDeferred.resolve([{
id: 1,
channel: 'lambda',
message: 'beta',
}]);
pollDeferred.resolve([{
id: 2,
channel: 'lambda',
message: 'epsilon',
}]);
assert.verifySteps([
["/longpolling/poll", "lambda"],
["notification", [["lambda","beta"]]],
["/longpolling/poll", "lambda"],
["notification", [["lambda","epsilon"]]],
["/longpolling/poll", "lambda"]
]);
parent.destroy();
});
QUnit.test('cross tab bus share message from a channel', function (assert) {
var done = assert.async();
assert.expect(5);
// master
var pollDeferredMaster = $.Deferred();
var parentMaster = new Widget();
testUtils.addMockEnvironment(parentMaster, {
data: {},
services: {
bus_service: BusService,
local_storage: LocalStorageServiceMock,
},
mockRPC: function (route, args) {
if (route === '/longpolling/poll') {
assert.step(['master', route, args.channels.join(',')]);
pollDeferredMaster = $.Deferred();
pollDeferredMaster.abort = (function () {
this.reject({message: "XmlHttpRequestError abort"}, $.Event());
}).bind(pollDeferredMaster);
return pollDeferredMaster;
}
return this._super.apply(this, arguments);
}
});
var master = new Widget(parentMaster);
master.appendTo($('#qunit-fixture'));
master.call('bus_service', 'onNotification', master, function (notifications) {
assert.step(['master', 'notification', notifications]);
});
master.call('bus_service', 'addChannel', 'lambda');
// slave
setTimeout(function () {
var parentSlave = new Widget();
testUtils.addMockEnvironment(parentSlave, {
data: {},
services: {
bus_service: BusService,
local_storage: LocalStorageServiceMock,
},
mockRPC: function (route, args) {
if (route === '/longpolling/poll') {
throw new Error("Can not use the longpolling of the slave client");
}
return this._super.apply(this, arguments);
}
});
var slave = new Widget(parentSlave);
slave.appendTo($('#qunit-fixture'));
slave.call('bus_service', 'onNotification', slave, function (notifications) {
assert.step(['slave', 'notification', notifications]);
});
slave.call('bus_service', 'addChannel', 'lambda');
pollDeferredMaster.resolve([{
id: 1,
channel: 'lambda',
message: 'beta',
}]);
assert.verifySteps([
["master", "/longpolling/poll", "lambda"],
["master", "notification", [["lambda", "beta"]]],
["slave", "notification", [["lambda", "beta"]]],
["master", "/longpolling/poll", "lambda"],
]);
parentMaster.destroy();
parentSlave.destroy();
done();
}, 3);
});
});});
+13 -8
View File
@@ -1,11 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="assets_backend" name="im assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/bus/static/src/js/bus.js"></script>
<script type="text/javascript" src="/bus/static/src/js/services/bus_service.js"></script>
</xpath>
</template>
</data>
<template id="assets_backend" name="im assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/bus/static/src/js/longpolling_bus.js"></script>
<script type="text/javascript" src="/bus/static/src/js/crosstab_bus.js"></script>
<script type="text/javascript" src="/bus/static/src/js/services/bus_service.js"></script>
</xpath>
</template>
<template id="qunit_suite" name="bus tests" inherit_id="web.qunit_suite">
<xpath expr="//script[last()]" position="after">
<script type="text/javascript" src="/bus/static/tests/bus_tests.js"/>
</xpath>
</template>
</odoo>
@@ -100,7 +100,7 @@ WebClient.include({
// in which the current user is involved is created, edited or deleted
this.calendar_notif_timeouts = {};
this.calendar_notif = {};
this.call('bus_service', 'getBus').on('notification', this, function (notifications) {
this.call('bus_service', 'onNotification', this, function (notifications) {
_.each(notifications, (function (notification) {
if (notification[0][1] === 'calendar.alarm') {
this.display_calendar_notif(notification[1]);
@@ -1,7 +1,7 @@
odoo.define('im_livechat.im_livechat', function (require) {
"use strict";
var bus = require('bus.bus').bus;
require('bus.BusService');
var concurrency = require('web.concurrency');
var config = require('web.config');
var core = require('web.core');
@@ -63,7 +63,6 @@ var LivechatButton = Widget.extend({
this._chatWindow = null;
this._messages = [];
this._serverURL = serverURL;
this._busBus = bus;
},
willStart: function () {
var self = this;
@@ -98,7 +97,7 @@ var LivechatButton = Widget.extend({
setTimeout(this._openChat.bind(this), this._rule.auto_popup_timer*1000);
}
}
this._busBus.on('notification', this, this._onNotification);
this.call('bus_service', 'onNotification', this, this._onNotification);
return this._super();
},
@@ -210,8 +209,8 @@ var LivechatButton = Widget.extend({
self._sendWelcomeMessage();
self._renderMessages();
self._busBus.add_channel(self._livechat.getUUID());
self._busBus.start_polling();
self.busBus.addChannel(self._livechat.getUUID());
self.busBus.startPolling();
utils.set_cookie('im_livechat_session', JSON.stringify(self._livechat.toData()), 60*60);
utils.set_cookie('im_livechat_auto_popup', JSON.stringify(false), 60*60);
@@ -138,8 +138,12 @@
<!-- thread windows -->
<script type="text/javascript" src="/mail/static/src/js/thread_windows/abstract_thread_window.js"></script>
<script type="text/javascript" src="/im_livechat/static/src/js/website_livechat_window.js"></script>
<script type="text/javascript" src="/bus/static/src/js/bus.js"></script>
<script type="text/javascript" src="/mail/static/src/js/thread_widget.js"></script>
<script type="text/javascript" src="/mail/static/src/js/thread_widget.js"></script>
<script type="text/javascript" src="/bus/static/src/js/longpolling_bus.js"></script>
<script type="text/javascript" src="/bus/static/src/js/crosstab_bus.js"></script>
<script type="text/javascript" src="/bus/static/src/js/services/bus_service.js"></script>
<script type="text/javascript" src="/mail/static/src/js/utils.js"></script>
<script type="text/javascript" src="/mail/static/src/js/document_viewer.js"></script>
<script type="text/javascript" src="/im_livechat/static/src/js/im_livechat.js"></script>
@@ -175,9 +179,10 @@
odoo.define('im_livechat.livesupport', function (require) {
<t t-if="info['available']" t-translation="off">
var websiteRoot = require('root.widget');
var im_livechat = require('im_livechat.im_livechat');
var button = new im_livechat.LivechatButton(
$('body'),
websiteRoot,
"<t t-esc="info['server_url']"/>",
<t t-raw="json.dumps(info.get('options', {}))"/>
);
@@ -10,7 +10,6 @@ var WebClient = require('web.WebClient');
var SupportChannel = require('im_support.SupportChannel');
var SupportMessage = require('im_support.SupportMessage');
var supportSession = require('im_support.SupportSession');
var supportBus = require('im_support.SupportBus');
var _t = core._t;
@@ -24,6 +23,7 @@ var SUPPORT_CHANNEL_STATE_KEY = 'im_support.channel_state';
* livechat operators from another database (the Support database).
*/
MailManager.include({
dependencies: (MailManager.prototype.dependencies || []).concat(['support_bus_service']),
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
@@ -43,7 +43,7 @@ MailManager.include({
this.pollingSupport = false;
// listen to notifications coming from Support longpolling
supportBus.on('notification', this, this._onSupportNotification);
this.call('support_bus_service', 'onNotification', this, this._onSupportNotification);
// check if there is a pending chat session with the Support
var timeoutTimestamp = this.call('local_storage', 'getItem', POLL_TIMEOUT_KEY);
@@ -79,10 +79,13 @@ MailManager.include({
* timeout delay to set
*/
startPollingSupport: function (pollingDelay) {
if (!('pollingSupport' in this)) {
return this.initSupport();
}
if (!this.pollingSupport) {
this.pollingSupport = true;
supportBus.add_channel(this.supportChannelUUID);
supportBus.start_polling();
this.call('support_bus_service', 'addChannel', this.supportChannelUUID);
this.call('support_bus_service', 'startPolling');
this._setPollTimeout(pollingDelay);
}
},
@@ -212,7 +215,7 @@ MailManager.include({
*/
_stopPollingSupport: function () {
this.pollingSupport = false;
supportBus.stop_polling();
this.call('support_bus_service', 'stopPolling');
this.call('local_storage', 'removeItem', POLL_TIMEOUT_KEY);
},
+11 -13
View File
@@ -6,23 +6,21 @@ odoo.define('im_support.SupportBus', function (require) {
* to poll the Support server.
*/
var BusService = require('bus.BusService');
var supportSession = require('im_support.SupportSession');
var core = require('web.core');
var bus = require('bus.bus');
var SupportBusService = BusService.extend({
LOCAL_STORAGE_PREFIX: 'im_support',
POLL_ROUTE: '/longpolling/support_poll',
getSession: function () {
return supportSession;
},
});
var Bus;
if(typeof Storage !== "undefined"){
Bus = bus.CrossTabBus;
} else {
Bus = bus.Bus;
}
core.serviceRegistry.add('support_bus_service', SupportBusService);
var params = {
session: supportSession,
pollRoute: '/longpolling/support_poll',
};
return new Bus(params);
return SupportBusService;
});
@@ -1,11 +1,20 @@
odoo.define('im_support.test_utils', function (require) {
"use strict";
var mailTestUtils = require('mail.testUtils');
var supportSession = require('im_support.SupportSession');
var testUtils = require('web.test_utils');
mailTestUtils.MockMailService.include({
getServices: function () {
return _.extend(this._super(), {
support_bus_service: this.bus_service(),
});
},
});
/**
* Extended version of addMockEnvironment that mocks RPCs done to the Support
* server (CORS), and enables a longpolling to the Support server, optionally.
@@ -1,7 +1,6 @@
odoo.define('im_support.systray_tests', function (require) {
"use strict";
var supportBus = require('im_support.SupportBus');
var imSupportTestUtils = require('im_support.test_utils');
var mailTestUtils = require('mail.testUtils');
@@ -21,6 +20,7 @@ QUnit.module('systray', {
},
};
this.services = mailTestUtils.getMailServices();
this.supportParams = {
db_uuid: 'some_uuid',
support_token: 'ABCDEFGHIJ',
@@ -316,7 +316,7 @@ QUnit.test('receive messages in the Support channel', function (assert) {
channel_ids: [supportChannelID],
};
var notification = [[false, 'mail.channel'], data];
supportBus.trigger('notification', [notification]);
messagingMenu.call('support_bus_service', 'trigger', 'notification', [notification]);
assert.strictEqual($('.o_thread_window .o_thread_message').length, 1,
"there should be a new message in the thread");
+7 -1
View File
@@ -17,10 +17,17 @@
<template id="qunit_suite" name="im_support_tests" inherit_id="web.qunit_suite">
<xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/im_support/static/tests/helpers/test_utils.js"></script>
<script type="text/javascript" src="/im_support/static/tests/systray_no_support_tests.js"></script>
</xpath>
</template>
<template id="qunit_mobile_suite" name="im_support_mobile_tests" inherit_id="web.qunit_mobile_suite">
<xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/im_support/static/tests/helpers/test_utils.js"></script>
</xpath>
</template>
<template id="im_support.support_qunit_suite">
<t t-call="web.layout">
<t t-set="html_data" t-value="{'style': 'height: 100%;'}"/>
@@ -35,7 +42,6 @@
<t t-call="web.js_tests_assets"/>
<script type="text/javascript" src="/im_support/static/tests/helpers/test_utils.js"></script>
<script type="text/javascript" src="/im_support/static/tests/systray_tests.js"></script>
</t>
@@ -25,9 +25,9 @@ MailManager.include({
// retrieve the open DocumentThreads from the localStorage
var state = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY);
if (!state) {
this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY, '{}');
this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY, {});
} else {
this.isReady().then(this._updateDocumentThreadWindows.bind(this, JSON.parse(state)));
this.isReady().then(this._updateDocumentThreadWindows.bind(this, state));
}
// listen to localStorage changes to synchronize DocumentThread's
// windows between tabs
@@ -67,7 +67,7 @@ MailManager.include({
data.author_id[0] === session.partner_id
) {
var key = this.DOCUMENT_THREAD_MESSAGE_KEY;
this.call('local_storage', 'setItem', key, JSON.stringify(data));
this.call('local_storage', 'setItem', key, data);
}
return this._super.apply(this, arguments);
},
@@ -149,15 +149,12 @@ MailManager.include({
* @param {string} state.windowState ('closed', 'folded' or 'open')
*/
updateDocumentThreadState: function (threadID, state) {
var item = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY);
var states = JSON.parse(item);
var states = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY);
states = _.omit(states, function (state) {
return state.windowState === 'closed';
});
states[threadID] = state;
this.call('local_storage', 'setItem',
this.DOCUMENT_THREAD_STATE_KEY,
JSON.stringify(states));
this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY, states);
},
//--------------------------------------------------------------------------
@@ -223,11 +220,11 @@ MailManager.include({
_onStorage: function (ev) {
if (ev.key === this.DOCUMENT_THREAD_STATE_KEY) {
var state = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY);
this._updateDocumentThreadWindows(JSON.parse(state));
this._updateDocumentThreadWindows(state);
} else if (ev.key === this.DOCUMENT_THREAD_MESSAGE_KEY) {
var message = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_MESSAGE_KEY);
if (message) {
this.addMessage(JSON.parse(message));
this.addMessage(message);
}
}
},
@@ -51,7 +51,6 @@ var MailManager = AbstractService.extend({
* @override
*/
start: function () {
this._busBus = this.call('bus_service', 'getBus');
this._cannedResponses = [];
this._mailBus = new Bus(this);
this._commands = [];
@@ -470,7 +469,7 @@ var MailManager = AbstractService.extend({
channel = this._makeChannel(data, options);
if (channel.getType() === 'dm') {
this._pinnedDmPartners.push(channel.getDirectPartnerID());
this._busBus.update_option(
this.call('bus_service', 'updateOption',
'bus_presence_partner_ids',
this._pinnedDmPartners
);
@@ -862,7 +861,7 @@ var MailManager = AbstractService.extend({
});
}).then(function (result) {
self._updateFromServer(result);
self._busBus.start_polling();
self.call('bus_service', 'startPolling');
});
},
/**
@@ -1001,7 +1000,7 @@ var MailManager = AbstractService.extend({
* @param {boolean} options.isDisplayed
*/
_notifyIncomingMessage: function (message, options) {
if (this._busBus.is_odoo_focused() && options.isDisplayed) {
if (this.call('bus_service', 'isOdooFocused') && options.isDisplayed) {
// no need to notify
return;
}
@@ -1012,7 +1011,7 @@ var MailManager = AbstractService.extend({
var content = mailUtils.parseAndTransform(message.getBody(), mailUtils.stripHTML)
.substr(0, PREVIEW_MSG_MAX_SIZE);
if (!this._busBus.is_odoo_focused()) {
if (!this.call('bus_service', 'isOdooFocused')) {
this._outOfFocusUnreadMessageCounter++;
var tabTitle = _.str.sprintf(
_t("%d Messages"),
@@ -1112,7 +1111,7 @@ var MailManager = AbstractService.extend({
var index = this._pinnedDmPartners.indexOf(channel.getDirectPartnerID());
if (index > -1) {
this._pinnedDmPartners.splice(index, 1);
this._busBus.update_option(
this.call('bus_service', 'updateOption',
'bus_presence_partner_ids',
this._pinnedDmPartners
);
@@ -18,7 +18,7 @@ MailManager.include({
start: function () {
this._super.apply(this, arguments);
this._busBus.on('notification', this, this._onNotification);
this.call('bus_service', 'onNotification', this, this._onNotification);
},
//--------------------------------------------------------------------------
+2 -5
View File
@@ -424,8 +424,6 @@ QUnit.test('chatter: post, receive and star messages', function (assert) {
var done = assert.async();
assert.expect(27);
var bus = this.services.bus_service.prototype.bus;
this.data.partner.records[0].message_ids = [1];
this.data['mail.message'].records = [{
author_id: ["1", "John Doe"],
@@ -499,7 +497,7 @@ QUnit.test('chatter: post, receive and star messages', function (assert) {
type: 'toggle_star',
};
var notification = [[false, 'res.partner'], data];
bus.trigger('notification', [notification]);
form.call('bus_service', 'trigger', 'notification', [notification]);
return $.when();
}
return this._super(route, args);
@@ -784,7 +782,6 @@ QUnit.test('chatter: discard changes on message post with post_refresh "recipien
var getSuggestionsDef = $.Deferred();
var messages = [];
var bus = this.services.bus_service.prototype.bus;
var form = createView({
View: FormView,
model: 'partner',
@@ -1664,7 +1661,7 @@ QUnit.test('does not render and crash when destroyed before chat system is ready
intercepts: {
get_session: function (event) {
event.stopPropagation();
event.data.callback({uid: 1});
event.data.callback({uid: 1, origin: 'http://web'});
},
},
});
@@ -533,8 +533,6 @@ QUnit.test('author: send message in moderated channel', function (assert) {
assert.expect(4);
var done = assert.async();
var bus = this.services.bus_service.prototype.bus;
var messagePostDef = $.Deferred();
this.data.initMessaging = {
@@ -548,6 +546,7 @@ QUnit.test('author: send message in moderated channel', function (assert) {
},
};
var objectDiscuss;
createDiscuss({
id: 1,
context: {},
@@ -571,7 +570,7 @@ QUnit.test('author: send message in moderated channel', function (assert) {
message: message,
};
var notification = [metaData, notifData];
bus.trigger('notification', [notification]);
objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]);
messagePostDef.resolve();
return $.when(message.id);
@@ -583,6 +582,7 @@ QUnit.test('author: send message in moderated channel', function (assert) {
},
})
.then(function (discuss) {
objectDiscuss = discuss;
// go to channel 'general'
discuss.$('.o_mail_discuss_item[data-thread-id="1"]').click();
@@ -613,8 +613,6 @@ QUnit.test('author: sent message accepted in moderated channel', function (asser
assert.expect(8);
var done = assert.async();
var bus = this.services.bus_service.prototype.bus;
this.data.initMessaging = {
channel_slots: {
channel_channel: [{
@@ -674,7 +672,7 @@ QUnit.test('author: sent message accepted in moderated channel', function (asser
};
var metaData = [dbName, 'mail.channel'];
var notification = [metaData, messageData];
bus.trigger('notification', [notification]);
discuss.call('bus_service', 'trigger', 'notification', [notification]);
// check message is accepted
$message = discuss.$('.o_thread_message');
@@ -696,8 +694,6 @@ QUnit.test('author: sent message rejected in moderated channel', function (asser
assert.expect(5);
var done = assert.async();
var bus = this.services.bus_service.prototype.bus;
this.data.initMessaging = {
channel_slots: {
channel_channel: [{
@@ -753,7 +749,7 @@ QUnit.test('author: sent message rejected in moderated channel', function (asser
};
var metaData = [dbName, 'res.partner'];
var notification = [metaData, notifData];
bus.trigger('notification', [notification]);
discuss.call('bus_service', 'trigger', 'notification', [notification]);
// // check no message
assert.strictEqual(discuss.$('.o_thread_message').length, 0,
+12 -6
View File
@@ -146,7 +146,6 @@ QUnit.test('@ mention in channel', function (assert) {
assert.expect(34);
var done = assert.async();
var bus = this.services.bus_service.prototype.bus;
var fetchListenersDef = $.Deferred();
var receiveMessageDef = $.Deferred();
@@ -160,6 +159,7 @@ QUnit.test('@ mention in channel', function (assert) {
},
};
var objectDiscuss;
createDiscuss({
id: 1,
context: {},
@@ -182,7 +182,7 @@ QUnit.test('@ mention in channel', function (assert) {
channel_ids: [1],
};
var notification = [[false, 'mail.channel'], data];
bus.trigger('notification', [notification]);
objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]);
receiveMessageDef.resolve();
return $.when(42);
}
@@ -190,6 +190,8 @@ QUnit.test('@ mention in channel', function (assert) {
},
})
.then(function (discuss) {
objectDiscuss = discuss;
var $general = discuss.$('.o_mail_discuss_sidebar')
.find('.o_mail_discuss_item[data-thread-id=1]');
assert.strictEqual($general.length, 1,
@@ -432,7 +434,6 @@ QUnit.test('"Unstar all" button should reset the starred counter', function (ass
assert.expect(2);
var done = assert.async();
var bus = this.services.bus_service.prototype.bus;
var messageData = [];
_.each(_.range(1, 41), function (num) {
messageData.push({
@@ -458,6 +459,7 @@ QUnit.test('"Unstar all" button should reset the starred counter', function (ass
};
this.data['mail.message'].records = messageData;
var objectDiscuss;
createDiscuss({
id: 1,
context: {},
@@ -472,7 +474,7 @@ QUnit.test('"Unstar all" button should reset the starred counter', function (ass
type: 'toggle_star',
};
var notification = [[false, 'res.partner'], data];
bus.trigger('notification', [notification]);
objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]);
return $.when(42);
}
return this._super.apply(this, arguments);
@@ -480,6 +482,8 @@ QUnit.test('"Unstar all" button should reset the starred counter', function (ass
session: {partner_id: 1},
})
.then(function (discuss) {
objectDiscuss = discuss;
var $starred = discuss.$('.o_mail_discuss_sidebar').find('.o_mail_mailbox_title_starred');
var $starredCounter = $('.o_mail_mailbox_title_starred > .o_mail_sidebar_needaction');
@@ -624,7 +628,6 @@ QUnit.test('convert emoji sources to unicodes on message_post', function (assert
assert.expect(2);
var done = assert.async();
var bus = this.services.bus_service.prototype.bus;
var receiveMessageDef = $.Deferred();
this.data.initMessaging = {
@@ -637,6 +640,7 @@ QUnit.test('convert emoji sources to unicodes on message_post', function (assert
},
};
var objectDiscuss;
createDiscuss({
id: 1,
context: {},
@@ -654,7 +658,7 @@ QUnit.test('convert emoji sources to unicodes on message_post', function (assert
channel_ids: [1],
};
var notification = [[false, 'mail.channel'], data];
bus.trigger('notification', [notification]);
objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]);
receiveMessageDef.resolve();
return $.when(42);
}
@@ -662,6 +666,8 @@ QUnit.test('convert emoji sources to unicodes on message_post', function (assert
},
})
.then(function (discuss) {
objectDiscuss= discuss;
var $general = discuss.$('.o_mail_discuss_sidebar')
.find('.o_mail_discuss_item[data-thread-id=1]');
@@ -201,7 +201,7 @@ QUnit.test('post messages in a document thread window', function (assert) {
testUtils.intercept(messagingMenu, 'call_service', function (ev) {
if (ev.data.service === 'local_storage' && ev.data.method === 'setItem' &&
ev.data.args[0] === 'mail.document_threads_last_message') {
assert.strictEqual(ev.data.args[1], JSON.stringify(newMessage),
assert.deepEqual(ev.data.args[1], newMessage,
"should write sent message in local storage, to share info with other tabs");
}
}, true);
@@ -262,10 +262,10 @@ QUnit.test('open, fold, unfold and close a document thread window', function (as
$('.o_thread_window .o_thread_window_close').click();
assert.verifySteps([
['mail.document_threads_state', "{\"some.res.model_1\":{\"name\":\"Some Record\",\"windowState\":\"open\"}}"],
['mail.document_threads_state', "{\"some.res.model_1\":{\"name\":\"Some Record\",\"windowState\":\"folded\"}}"],
['mail.document_threads_state', "{\"some.res.model_1\":{\"name\":\"Some Record\",\"windowState\":\"open\"}}"],
['mail.document_threads_state', "{\"some.res.model_1\":{\"name\":\"Some Record\",\"windowState\":\"closed\"}}"],
['mail.document_threads_state', {"some.res.model_1": {"name": "Some Record", "windowState": "open"}}],
['mail.document_threads_state', {"some.res.model_1": {"name": "Some Record", "windowState": "folded"}}],
['mail.document_threads_state', {"some.res.model_1": {"name": "Some Record", "windowState": "open"}}],
['mail.document_threads_state', {"some.res.model_1": {"name": "Some Record", "windowState": "closed"}}],
]);
messagingMenu.destroy();
+4 -20
View File
@@ -1,12 +1,13 @@
odoo.define('mail.testUtils', function (require) {
"use strict";
var BusService = require('bus.BusService');
var Discuss = require('mail.Discuss');
var MailService = require('mail.Service');
var AbstractService = require('web.AbstractService');
var AbstractStorageService = require('web.AbstractStorageService');
var Bus = require('web.Bus');
var Class = require('web.Class');
var ControlPanel = require('web.ControlPanel');
var RamStorage = require('web.RamStorage');
@@ -63,27 +64,10 @@ function createDiscuss(params) {
var MockMailService = Class.extend({
bus_service: function () {
var MockBus = Bus.extend({
/**
* Do nothing
*/
startPolling: function () {},
return BusService.extend({
_poll: function () {}, // Do nothing
isOdooFocused: function () { return true; },
});
return AbstractService.extend({
bus: new MockBus(this),
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @returns {Bus}
*/
getBus: function () {
return this.bus;
}
});
},
mail_service: function () {
return MailService;
@@ -149,8 +149,6 @@ QUnit.test('messaging menu widget: messaging menu with 1 record', function (asse
QUnit.test('messaging menu widget: no crash when clicking on inbox notification not associated to a document', function (assert) {
assert.expect(3);
var bus = this.services.bus_service.prototype.bus;
var messagingMenu = new MessagingMenu();
testUtils.addMockEnvironment(messagingMenu, {
services: this.services,
@@ -185,7 +183,7 @@ QUnit.test('messaging menu widget: no crash when clicking on inbox notification
var notifications = [
[['myDB', 'ir.needaction'], message]
];
bus.trigger('notification', notifications);
messagingMenu.call('bus_service', 'trigger', 'notification', notifications);
// Open messaging menu
messagingMenu.$('.dropdown-toggle').click();
@@ -40,7 +40,6 @@ QUnit.module('mail', {
self.services.mail_service.prototype.THREAD_WINDOW_APPENDTO = '#qunit-fixture';
}
params.services = _.clone(params.services);
testUtils.addMockEnvironment(widget, params);
return widget;
};
@@ -29,8 +29,9 @@ var AbstractStorageService = AbstractService.extend({
* @param {string} key
* @returns {string}
*/
getItem: function(key) {
return this.storage.getItem(key);
getItem: function(key, defaultValue) {
var val = this.storage.getItem(key);
return val ? JSON.parse(val) : defaultValue;
},
/**
* Removes the given key from the storage
@@ -47,7 +48,14 @@ var AbstractStorageService = AbstractService.extend({
* @param {string} value
*/
setItem: function(key, value) {
this.storage.setItem(key,value);
this.storage.setItem(key, JSON.stringify(value));
},
/**
* Add an handler on storage event
*
*/
onStorage: function () {
this.storage.on.apply(this.storage, ["storage"].concat(Array.prototype.slice.call(arguments)));
},
});
+39 -2
View File
@@ -2,18 +2,55 @@ odoo.define('web.local_storage', function (require) {
'use strict';
var RamStorage = require('web.RamStorage');
var mixins = require('web.mixins');
// use a fake localStorage in RAM if the native localStorage is unavailable
// (e.g. private browsing in Safari)
var storage;
var localStorage = window.localStorage;
try {
var uid = new Date();
localStorage.setItem(uid, uid);
localStorage.removeItem(uid);
/*
* We create an intermediate object in order to triggered the storage on
* this object. the localStorage. This simplifies testing and usage as
* starages are commutable in services without change. Also, objects
* that use storage do not have to know that events go through window,
* it's not up to them to handle these cases.
*/
storage = (function () {
var storage = Object.create(_.extend({
getItem: localStorage.getItem.bind(localStorage),
setItem: localStorage.setItem.bind(localStorage),
removeItem: localStorage.removeItem.bind(localStorage),
clear: localStorage.clear.bind(localStorage),
},
mixins.EventDispatcherMixin
));
storage.init();
$(window).on('storage', function (e) {
var key = e.originalEvent.key;
var newValue = e.originalEvent.newValue;
try {
JSON.parse(newValue);
if (localStorage.getItem(key) === newValue) {
storage.trigger('storage', {
key: key,
newValue: newValue,
});
}
} catch (error) {}
});
return storage;
})();
} catch (exception) {
localStorage = new RamStorage();
console.warn('Fail to load localStorage');
storage = new RamStorage();
}
return localStorage;
return storage;
});
+10 -3
View File
@@ -8,13 +8,18 @@ odoo.define('web.RamStorage', function (require) {
*/
var Class = require('web.Class');
var mixins = require('web.mixins');
var RamStorage = Class.extend({
var RamStorage = Class.extend(mixins.EventDispatcherMixin, {
/**
* @constructor
*/
init: function () {
this.storage = Object.create(null);
mixins.EventDispatcherMixin.init.call(this);
if (!this.storage) {
this.clear();
}
},
//--------------------------------------------------------------------------
@@ -25,7 +30,7 @@ var RamStorage = Class.extend({
* Removes all data from the storage
*/
clear: function () {
this.init();
this.storage = Object.create(null);
},
/**
* Returns the value associated with a given key in the storage
@@ -43,6 +48,7 @@ var RamStorage = Class.extend({
*/
removeItem: function (key) {
delete this.storage[key];
this.trigger('storage', {key: key, newValue: null});
},
/**
* Adds a given key-value pair to the storage, or update the value of the
@@ -53,6 +59,7 @@ var RamStorage = Class.extend({
*/
setItem: function (key, value) {
this.storage[key] = value;
this.trigger('storage', {key: key, newValue: value});
},
});
@@ -135,7 +135,8 @@ var ServicesMixin = {
var query = rpc.buildQuery(params);
var def = this.call('ajax', 'rpc', query.route, query.params, options) || $.Deferred();
var promise = def.promise();
promise.abort = def.abort ? def.abort.bind(def) : function abort () {def.reject();};
var abort = (def.abort ? def.abort : def.reject) || function () {};
promise.abort = abort.bind(def);
return promise;
},
loadFieldView: function (dataset, view_id, view_type, options) {
@@ -2,18 +2,55 @@ odoo.define('web.sessionStorage', function (require) {
'use strict';
var RamStorage = require('web.RamStorage');
var mixins = require('web.mixins');
// use a fake sessionStorage in RAM if the native sessionStorage is unavailable
// (e.g. private browsing in Safari)
var storage;
var sessionStorage = window.sessionStorage;
try {
var uid = new Date();
sessionStorage.setItem(uid, uid);
sessionStorage.removeItem(uid);
/*
* We create an intermediate object in order to triggered the storage on
* this object. the sessionStorage. This simplifies testing and usage as
* starages are commutable in services without change. Also, objects
* that use storage do not have to know that events go through window,
* it's not up to them to handle these cases.
*/
storage = (function () {
var storage = Object.create(_.extend({
getItem: sessionStorage.getItem.bind(sessionStorage),
setItem: sessionStorage.setItem.bind(sessionStorage),
removeItem: sessionStorage.removeItem.bind(sessionStorage),
clear: sessionStorage.clear.bind(sessionStorage),
},
mixins.EventDispatcherMixin
));
storage.init();
$(window).on('storage', function (e) {
var key = e.originalEvent.key;
var newValue = e.originalEvent.newValue;
try {
JSON.parse(newValue);
if (sessionStorage.getItem(key) === newValue) {
storage.trigger('storage', {
key: key,
newValue: newValue,
});
}
} catch (error) {}
});
return storage;
})();
} catch (exception) {
sessionStorage = new RamStorage();
console.warn('Fail to load sessionStorage');
storage = new RamStorage();
}
return sessionStorage;
return storage;
});
+13 -1
View File
@@ -4,6 +4,7 @@ odoo.define('web_editor.root_widget', function (require) {
var Class = require('web.Class');
var dom = require('web.dom');
var mixins = require('web.mixins');
var session = require('web.session');
var Widget = require('web.Widget');
/**
@@ -15,13 +16,13 @@ var Widget = require('web.Widget');
var RootWidget = Widget.extend({
custom_events: _.extend({}, Widget.prototype.custom_events || {}, {
registry_update: '_onRegistryUpdate',
get_session: '_onGetSession',
}),
/**
* @constructor
*/
init: function () {
this._super.apply(this, arguments);
this._widgets = [];
this._listenToUpdates = false;
this._getRegistry().setParent(this);
@@ -97,6 +98,17 @@ var RootWidget = Widget.extend({
// Handlers
//--------------------------------------------------------------------------
/**
* Get the curuent session module.
*
* @private
* @param {OdooEvent} ev
*/
_onGetSession: function (event) {
if (event.data.callback) {
event.data.callback(session);
}
},
/**
* Called when the linked registry is updated after this `RootWidget`
*
@@ -11,7 +11,11 @@
<!-- thread window -->
<script type="text/javascript" src="/mail/static/src/js/thread_windows/abstract_thread_window.js"></script>
<script type="text/javascript" src="/im_livechat/static/src/js/website_livechat_window.js"></script>
<script type="text/javascript" src="/bus/static/src/js/bus.js"></script>
<script type="text/javascript" src="/bus/static/src/js/longpolling_bus.js"></script>
<script type="text/javascript" src="/bus/static/src/js/crosstab_bus.js"></script>
<script type="text/javascript" src="/bus/static/src/js/services/bus_service.js"></script>
<script type="text/javascript" src="/mail/static/src/js/document_viewer.js"></script>
<script type="text/javascript" src="/mail/static/src/js/thread_widget.js"></script>
<script type="text/javascript" src="/mail/static/src/js/utils.js"></script>