summaryrefslogtreecommitdiff
path: root/addons/bus/static/src
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/bus/static/src
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/bus/static/src')
-rw-r--r--addons/bus/static/src/js/crosstab_bus.js363
-rw-r--r--addons/bus/static/src/js/longpolling_bus.js262
-rw-r--r--addons/bus/static/src/js/services/bus_service.js154
-rw-r--r--addons/bus/static/src/js/web_client_bus.js109
4 files changed, 888 insertions, 0 deletions
diff --git a/addons/bus/static/src/js/crosstab_bus.js b/addons/bus/static/src/js/crosstab_bus.js
new file mode 100644
index 00000000..bb2b2a00
--- /dev/null
+++ b/addons/bus/static/src/js/crosstab_bus.js
@@ -0,0 +1,363 @@
+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();
+ // 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', 0);
+ 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);
+ },
+ /**
+ * @return {string}
+ */
+ getTabId: function () {
+ return this._id;
+ },
+ /**
+ * 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);
+
+ this._registerWindowUnload();
+
+ if (!this._callLocalStorage('getItem', 'master')) {
+ 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.bind(this), hbPeriod);
+ },
+ /**
+ * @private
+ */
+ _registerWindowUnload: function () {
+ $(window).on('unload.' + this._id, this._onUnload.bind(this));
+ },
+ /**
+ * 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._callLocalStorage('setItem', 'master', true);
+ 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('master') && !value) {
+ //master was unloaded
+ this._startElection();
+ }
+
+ // last notification id changed
+ if (key === this._generateKey('last')) {
+ this._lastNotificationID = value || 0;
+ }
+ // notifications changed
+ else if (key === this._generateKey('notification')) {
+ if (!this._isMasterTab) {
+ this.trigger("notification", value);
+ }
+ }
+ // update channels
+ else if (key === this._generateKey('channels')) {
+ this._channels = value;
+ }
+ // update options
+ else if (key === this._generateKey('options')) {
+ this._options = value;
+ }
+ // update focus
+ else 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', 'master');
+ }
+ },
+});
+
+return CrossTabBus;
+
+});
+
diff --git a/addons/bus/static/src/js/longpolling_bus.js b/addons/bus/static/src/js/longpolling_bus.js
new file mode 100644
index 00000000..9c8df877
--- /dev/null
+++ b/addons/bus/static/src/js/longpolling_bus.js
@@ -0,0 +1,262 @@
+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');
+
+ // the _id is modified by crosstab_bus, so we can't use it to unbind the events in the destroy.
+ this._longPollingBusId = this._id;
+ this._options = {};
+ this._channels = [];
+
+ // bus presence
+ this._lastPresenceTime = new Date().getTime();
+ $(window).on("focus." + this._longPollingBusId, this._onFocusChange.bind(this, {focus: true}));
+ $(window).on("blur." + this._longPollingBusId, this._onFocusChange.bind(this, {focus: false}));
+ $(window).on("unload." + this._longPollingBusId, this._onFocusChange.bind(this, {focus: false}));
+
+ $(window).on("click." + this._longPollingBusId, this._onPresence.bind(this));
+ $(window).on("keydown." + this._longPollingBusId, this._onPresence.bind(this));
+ $(window).on("keyup." + this._longPollingBusId, this._onPresence.bind(this));
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this.stopPolling();
+ $(window).off("focus." + this._longPollingBusId);
+ $(window).off("blur." + this._longPollingBusId);
+ $(window).off("unload." + this._longPollingBusId);
+ $(window).off("click." + this._longPollingBusId);
+ $(window).off("keydown." + this._longPollingBusId);
+ $(window).off("keyup." + this._longPollingBusId);
+ this._super();
+ },
+ //--------------------------------------------------------------------------
+ // 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(),
+ });
+ 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._makePoll(data);
+ this._pollRpc.then(function (result) {
+ self._pollRpc = false;
+ self._onPoll(result);
+ self._poll();
+ }).guardedCatch(function (result) {
+ self._pollRpc = false;
+ // no error popup if request is interrupted or fails for any reason
+ result.event.preventDefault();
+ if (result.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));
+ }
+ });
+ },
+
+ /**
+ * @private
+ * @param data: object with poll parameters
+ */
+ _makePoll: function(data) {
+ return this._rpc({route: this.POLL_ROUTE, params: data}, {shadow : true, timeout: 60000});
+ },
+
+ //--------------------------------------------------------------------------
+ // 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;
+
+});
diff --git a/addons/bus/static/src/js/services/bus_service.js b/addons/bus/static/src/js/services/bus_service.js
new file mode 100644
index 00000000..39b61da5
--- /dev/null
+++ b/addons/bus/static/src/js/services/bus_service.js
@@ -0,0 +1,154 @@
+odoo.define('bus.BusService', function (require) {
+"use strict";
+
+var CrossTab = require('bus.CrossTab');
+var core = require('web.core');
+var ServicesMixin = require('web.ServicesMixin');
+const session = require('web.session');
+
+var BusService = CrossTab.extend(ServicesMixin, {
+ dependencies : ['local_storage'],
+
+ // properties
+ _audio: null,
+
+ /**
+ * As the BusService doesn't extend AbstractService, we have to replicate
+ * here what is done in AbstractService
+ *
+ * @param {Object} env
+ */
+ init: function (env) {
+ this.env = env;
+ this._super();
+ },
+
+ /**
+ * Replicate the behavior of AbstractService:
+ *
+ * Directly calls the requested service, instead of triggering a
+ * 'call_service' event up, which wouldn't work as services have no parent.
+ *
+ * @param {OdooEvent} ev
+ */
+ _trigger_up: function (ev) {
+ if (ev.name === 'call_service') {
+ const payload = ev.data;
+ let args = payload.args || [];
+ if (payload.service === 'ajax' && payload.method === 'rpc') {
+ // ajax service uses an extra 'target' argument for rpc
+ args = args.concat(ev.target);
+ }
+ const service = this.env.services[payload.service];
+ const result = service[payload.method].apply(service, args);
+ payload.callback(result);
+ }
+ },
+ /**
+ * This method is necessary in order for this Class to be used to instantiate services
+ *
+ * @abstract
+ */
+ start: function () {},
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Send a notification, and notify once per browser's tab
+ *
+ * @param {string} title
+ * @param {string} content
+ * @param {function} [callback] if given callback will be called when user clicks on notification
+ */
+ sendNotification: function (title, content, callback) {
+ if (window.Notification && Notification.permission === "granted") {
+ if (this.isMasterTab()) {
+ try {
+ this._sendNativeNotification(title, content, callback);
+ } catch (error) {
+ // Notification without Serviceworker in Chrome Android doesn't works anymore
+ // So we fallback to do_notify() in this case
+ // https://bugs.chromium.org/p/chromium/issues/detail?id=481856
+ if (error.message.indexOf('ServiceWorkerRegistration') > -1) {
+ this.do_notify(title, content);
+ this._beep();
+ } else {
+ throw error;
+ }
+ }
+ }
+ } else {
+ this.do_notify(title, content);
+ 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
+ //--------------------------------------------------------------------------
+
+ /**
+ * Lazily play the 'beep' audio on sent notification
+ *
+ * @private
+ */
+ _beep: function () {
+ if (typeof(Audio) !== "undefined") {
+ if (!this._audio) {
+ this._audio = new Audio();
+ var ext = this._audio.canPlayType("audio/ogg; codecs=vorbis") ? ".ogg" : ".mp3";
+ this._audio.src = session.url("/mail/static/src/audio/ting" + ext);
+ }
+ Promise.resolve(this._audio.play()).catch(_.noop);
+ }
+ },
+ /**
+ * Show a browser notification
+ *
+ * @private
+ * @param {string} title
+ * @param {string} content
+ * @param {function} [callback] if given callback will be called when user clicks on notification
+ */
+ _sendNativeNotification: function (title, content, callback) {
+ var notification = new Notification(
+ // The native Notification API works with plain text and not HTML
+ // unescaping is safe because done only at the **last** step
+ _.unescape(title),
+ {
+ body: _.unescape(content),
+ icon: "/mail/static/src/img/odoobot_transparent.png"
+ });
+ notification.onclick = function () {
+ window.focus();
+ if (this.cancel) {
+ this.cancel();
+ } else if (this.close) {
+ this.close();
+ }
+ if (callback) {
+ callback();
+ }
+ };
+ },
+
+});
+
+core.serviceRegistry.add('bus_service', BusService);
+
+return BusService;
+
+});
diff --git a/addons/bus/static/src/js/web_client_bus.js b/addons/bus/static/src/js/web_client_bus.js
new file mode 100644
index 00000000..6683b565
--- /dev/null
+++ b/addons/bus/static/src/js/web_client_bus.js
@@ -0,0 +1,109 @@
+odoo.define('bus.WebClient', function (require) {
+ "use strict";
+
+ const core = require('web.core');
+ const WebClient = require('web.WebClient');
+
+ const _t = core._t;
+
+ WebClient.include({
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Detects the presence of assets in DOM's HEAD
+ *
+ * @override
+ */
+ async start() {
+ this._assetsChangedNotificationId = null;
+ this._assets = {};
+ await this._super(...arguments);
+ },
+ /**
+ * Assigns handler to bus notification
+ *
+ * @override
+ */
+ show_application() {
+ const shown = this._super(...arguments);
+ document.querySelectorAll('*[data-asset-xmlid]').forEach(el => {
+ this._assets[el.getAttribute('data-asset-xmlid')] = el.getAttribute('data-asset-version');
+ });
+ this.call('bus_service', 'onNotification', this, this._onNotification);
+ this.call('bus_service', 'addChannel', 'bundle_changed');
+ return shown;
+ },
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * Displays one notification on user's screen when assets have changed
+ *
+ * @private
+ */
+ _displayBundleChangedNotification() {
+ if (!this._assetsChangedNotificationId) {
+ // Wrap the notification inside a delay.
+ // The server may be overwhelmed with recomputing assets
+ // We wait until things settle down
+ clearTimeout(this._bundleNotifTimerID);
+ this._bundleNotifTimerID = setTimeout(() => {
+ this._assetsChangedNotificationId = this.call('notification', 'notify', {
+ title: _t('Refresh'),
+ message: _t('The page appears to be out of date.'),
+ sticky: true,
+ onClose: () => {
+ this._assetsChangedNotificationId = null;
+ },
+ buttons: [{
+ text: _t('Refresh'),
+ primary: true,
+ click: () => {
+ window.location.reload(true);
+ }
+ }],
+ });
+ }, this._getBundleNotificationDelay());
+ }
+ },
+ /**
+ * Computes a random delay to avoid hammering the server
+ * when bundles change with all the users reloading
+ * at the same time
+ *
+ * @private
+ * @return {number} delay in milliseconds
+ */
+ _getBundleNotificationDelay() {
+ return 10000 + Math.floor(Math.random()*50) * 1000;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Reacts to bus's notification
+ *
+ * @private
+ * @param {Array} notifications: list of received notifications
+ */
+ _onNotification(notifications) {
+ for (const notif of notifications) {
+ if (notif[0][1] === 'bundle_changed') {
+ const bundleXmlId = notif[1][0];
+ const bundleVersion = notif[1][1];
+ if (bundleXmlId in this._assets && bundleVersion !== this._assets[bundleXmlId]) {
+ this._displayBundleChangedNotification();
+ break;
+ }
+ }
+ }
+ }
+ });
+});