[IMP] *: adapt client code to use websocket instead of longpolling

*: bus, calendar, hr_attendance, iap_mail, im_livechat, project, snailmail,
snailmail_account, survey, web, web_editor, website_livechat.

This commit is part of the websocket integration in Odoo.
This bus service now communicates with a shared worker in order to provide
a single websocket connection for multiple tabs. It is designed to
be used as a websocket except that events are slightly different,
re-connection is handled automatically. If the browser does not support shared
worker (Safari), the service fallback on a simple web worker.

Available events are:
   - connect : fired upon a successful connection.
   - disconnect : fired upon reception of the websocket close event.
     The close code and reason are given to the listeners callback.
   - reconnect : fired upon a successful re-connection.
   - reconnecting : triggered when the worker starts to try reconnecting.
   - notification : fired upon the reception of notifications.

Since multiple tabs are now handled by a worker, the cross_tab bus is no longer
required and has been removed.

Part-of: odoo/odoo#75510
This commit is contained in:
Matthieu Stockbauer
2022-08-23 17:55:10 +02:00
committed by tsm-odoo
parent de6de48deb
commit fe699a241d
37 changed files with 919 additions and 588 deletions
+10 -5
View File
@@ -9,11 +9,11 @@
],
'installable': True,
'assets': {
'web.assets_backend': [
'bus/static/src/**/*',
],
'web.assets_frontend': [
'bus/static/src/**/*.js',
'web.assets_common': [
'bus/static/src/*.js',
'bus/static/src/services/**/*.js',
'bus/static/src/workers/websocket_worker.js',
'bus/static/src/workers/websocket_worker_utils.js',
],
'web.qunit_suite_tests': [
'bus/static/tests/**/*.js',
@@ -21,6 +21,11 @@
'web.qunit_mobile_suite_tests': [
'bus/static/tests/helpers/*.js',
],
'bus.websocket_worker_assets': [
'web/static/src/legacy/js/promise_extension.js',
'web/static/src/boot.js',
'bus/static/src/workers/*',
],
},
'license': 'LGPL-3',
}
+9
View File
@@ -3,6 +3,7 @@
import json
from odoo.http import Controller, request, route
from odoo.addons.base.models.assetsbundle import AssetsBundle
from ..models.bus import channel_with_db
from ..websocket import WebsocketConnectionHandler
@@ -37,3 +38,11 @@ class WebsocketController(Controller):
@route('/websocket/update_bus_presence', type='http', auth='public', cors='*')
def update_bus_presence(self, inactivity_period):
request.env['ir.websocket']._update_bus_presence(inactivity_period)
@route('/bus/websocket_worker_bundle', type='http', auth='public', cors='*')
def get_websocket_worker_bundle(self):
bundle = 'bus.websocket_worker_assets'
files, _ = request.env["ir.qweb"]._get_asset_content(bundle)
asset = AssetsBundle(bundle, files)
stream = request.env['ir.binary']._get_stream_from(asset.js())
return stream.get_response()
-213
View File
@@ -1,213 +0,0 @@
/** @odoo-module **/
import { browser } from '@web/core/browser/browser';
import { Longpolling } from '@bus/longpolling_bus';
/**
* CrossTab
*
* This is an extension of the longpolling bus with browser cross-tab synchronization.
* It uses the multi_tab service.
* - only the main tab handles longpolling.
* - tabs are synchronized by means of the multi_tab service.
*
* multi_tab shared values are:
* - channels : shared public channel list to listen during the poll
* - options : shared options
* - notification : the received notifications from the last poll
*
* trigger:
* - notification : when a notification is receive from the long polling
*/
export class CrossTab extends Longpolling {
constructor(env, services) {
super(env, services);
// properties
this._isRegistered = false;
var now = new Date().getTime();
this._currentTabChannels = new Set();
if (this.env.services['multi_tab'].getSharedValue('last_ts', 0) + 50000 < now) {
this.env.services['multi_tab'].removeSharedValue('last');
}
this._lastNotificationID = this.env.services['multi_tab'].getSharedValue('last', 0);
const onSharedValueUpdated = (event) => this._onSharedValueUpdated(event);
const stopPolling = () => this.stopPolling();
const startPolling = () => this.startPolling();
services['multi_tab'].bus.addEventListener('shared_value_updated', onSharedValueUpdated);
services['multi_tab'].bus.addEventListener('no_longer_main_tab', stopPolling);
services['multi_tab'].bus.addEventListener('become_main_tab', startPolling);
browser.addEventListener('unload', () => {
services['multi_tab'].bus.removeEventListener('shared_value_updated', onSharedValueUpdated);
services['multi_tab'].bus.removeEventListener('no_longer_main_tab', stopPolling);
services['multi_tab'].bus.removeEventListener('become_main_tab', startPolling);
this._currentTabChannels.clear();
this._updateChannels();
});
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Share the bus channels with the others tab by the local storage
*
* @override
*/
addChannel(channel) {
this._currentTabChannels.add(channel);
super.addChannel(channel);
this._updateChannels();
}
/**
* Share the bus channels with the others tab by the local storage
*
* @override
*/
deleteChannel(channel) {
this._currentTabChannels.delete(channel);
super.deleteChannel(channel);
this._updateChannels();
}
/**
* Use the local storage to share the long polling from the master tab.
*
* @override
*/
startPolling() {
if (!this._isRegistered) {
this._isRegistered = true;
if (this.env.services['multi_tab'].isOnMainTab()) {
this.env.services['multi_tab'].setSharedValue('options', this._options);
} else {
this._options = this.env.services['multi_tab'].getSharedValue('options', this._options);
}
this._updateChannels();
}
if (this.env.services['multi_tab'].isOnMainTab()) {
super.startPolling();
}
}
/**
* Share the option with the local storage
*
* @override
*/
updateOption(key, value) {
super.updateOption(key, value);
this.env.services['multi_tab'].setSharedValue('options', this._options);
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Update localstorage channels of with the channels of this tab.
*
* @private
* @return {boolean} true if the aggregated channels has changed.
*/
_updateChannels() {
const currentPeerIds = new Set(Object.keys(this.env.services['multi_tab'].getSharedValue('lastPresenceByTab', {})));
const peerChannels = this.env.services['multi_tab'].getSharedValue('channels', {});
const peerChannelsBefore = JSON.stringify(peerChannels);
peerChannels[this._id] = Array.from(this._currentTabChannels);
// Clean outdated channels.
for (const channelPeerId of Object.keys(peerChannels)) {
if (!currentPeerIds.has(channelPeerId)) {
delete peerChannels[channelPeerId];
}
}
const peerChannelsAfter = JSON.stringify(peerChannels);
if (peerChannelsBefore !== peerChannelsAfter) {
this.env.services['multi_tab'].setSharedValue('channels', peerChannels);
}
const allChannels = new Set();
for (const channels of Object.values(peerChannels)) {
for (const channel of channels) {
allChannels.add(channel);
}
}
// Insure the current tab channels are always in the aggregated channels
// in case this tab is not in the currentPeerIds nor peerChannels.
for (const channel of this._currentTabChannels) {
allChannels.add(channel);
}
const allChannelsSorted = Array.from(allChannels).sort();
if (JSON.stringify(allChannelsSorted) === JSON.stringify(this._channels.sort())) {
return false;
} else {
this._channels = allChannelsSorted;
return true;
}
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* If it's the master tab, the notifications ares broadcasted to other tabs by the
* local storage.
*
* @override
*/
_onPoll(notifications) {
var notifs = super._onPoll(notifications);
if (this.env.services['multi_tab'].isOnMainTab() && notifs.length) {
this.env.services['multi_tab'].setSharedValue('last', this._lastNotificationID);
this.env.services['multi_tab'].setSharedValue('last_ts', new Date().getTime());
this.env.services['multi_tab'].setSharedValue('notification', notifs);
}
}
/**
* Handler when a shared value is updated.
*
* @private
* @param {CustomEvent} ev
* @param {object} [ev.detail]
* @param {string} [ev.detail.key] Key of the value that have changed.
* @param {any} [ev.detail.newValue] New value associated to the key.
*/
_onSharedValueUpdated({ detail }) {
const { key, newValue } = detail;
const value = JSON.parse(newValue);
// last notification id changed
if (key === 'last') {
this._lastNotificationID = value || 0;
}
// notifications changed
else if (key === 'notification') {
if (!this.env.services['multi_tab'].isOnMainTab()) {
this.trigger("notification", value);
}
}
// update channels
else if (key === 'channels') {
if (this._updateChannels()) {
this._restartPolling();
};
}
// update options
else if (key === 'options') {
this._options = value;
}
}
}
@@ -0,0 +1,47 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
/**
* This service updates periodically the user presence in order for the
* im_status to be up to date.
*/
export const imStatusService = {
dependencies: ['bus_service', 'multi_tab', 'presence'],
start(env, { bus_service, multi_tab, presence }) {
const UPDATE_BUS_PRESENCE_DELAY = 30000;
let updateBusPresenceInterval;
function updateBusPresence() {
const now = new Date().getTime();
bus_service.send("update_presence", now - presence.getLastPresence());
}
function stopUpdatingBusPresence() {
clearInterval(updateBusPresenceInterval);
updateBusPresenceInterval = null;
}
function startUpdatingBusPresence() {
if (updateBusPresenceInterval || !multi_tab.isOnMainTab()) {
return;
}
updateBusPresence();
updateBusPresenceInterval = setInterval(
updateBusPresence,
UPDATE_BUS_PRESENCE_DELAY,
);
}
if (multi_tab.isOnMainTab()) {
startUpdatingBusPresence();
}
env.bus.addEventListener('become_main_tab', startUpdatingBusPresence);
bus_service.addEventListener('reconnect', startUpdatingBusPresence);
env.bus.addEventListener('no_longer_main_tab', stopUpdatingBusPresence);
bus_service.addEventListener('disconnect', stopUpdatingBusPresence);
},
};
registry.category('services').add('im_status', imStatusService);
-202
View File
@@ -1,202 +0,0 @@
/** @odoo-module **/
const { EventBus } = owl;
/**
* Event Longpolling bus used to bind events on the server long polling return
*
* trigger:
* - notification : when a notification is receive from the long polling
*/
export class Longpolling extends EventBus {
constructor(env, { multi_tab }) {
super();
this.env = env;
// CONSTANTS
this.PARTNERS_PRESENCE_CHECK_PERIOD = 30000; // don't check presence more than once every 30s
this.ERROR_RETRY_DELAY = 10000; // 10 seconds
this.POLL_ROUTE = '/longpolling/poll';
// PROPERTIES
this._isActive = null;
this._id = multi_tab.currentTabId;
this._lastNotificationID = 0;
this._pollRetryTimeout = null;
// 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 = [];
}
//--------------------------------------------------------------------------
// 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(channel) {
if (this._channels.indexOf(channel) === -1) {
this._channels.push(channel);
this._restartPolling();
}
}
/**
* 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(channel) {
var index = this._channels.indexOf(channel);
if (index !== -1) {
this._channels.splice(index, 1);
if (this._pollRpc) {
this._pollRpc.abort();
}
}
}
/**
* Start a long polling, i.e. it continually opens a long poll
* connection as long as it is not stopped (@see `stopPolling`)
*/
startPolling() {
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() {
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(key, value) {
this._options[key] = value;
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* 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() {
var self = this;
if (!this._isActive) {
return;
}
var now = new Date().getTime();
var options = _.extend({}, this._options, {
bus_inactivity: now - this.env.services['presence'].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();
}).catch(function (result) {
self._pollRpc = false;
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(params) {
return this.env.services.rpc(this.POLL_ROUTE, params, { silent: true, timeout: 60000 });
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* 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(notifications) {
var self = this;
var notifs = _.map(notifications, function (notif) {
if (notif.id > self._lastNotificationID) {
self._lastNotificationID = notif.id;
}
return notif.message;
});
this.trigger("notification", notifs);
return notifs;
}
/**
* Restart polling.
*
* @private
*/
_restartPolling() {
if (this._pollRpc) {
this._pollRpc.abort();
} else {
this.startPolling();
}
}
}
@@ -12,7 +12,6 @@ export const assetsWatchdogService = {
let bundleNotifTimerID = null;
bus_service.addEventListener('notification', onNotification.bind(this));
bus_service.startPolling();
/**
* Displays one notification on user's screen when assets have changed
+84 -4
View File
@@ -1,13 +1,93 @@
/** @odoo-module **/
import { CrossTab } from '@bus/crosstab_bus';
import { WEBSOCKET_CLOSE_CODES } from "@bus/workers/websocket_worker";
import { browser } from "@web/core/browser/browser";
import { registry } from '@web/core/registry';
const { EventBus } = owl;
const NO_POPUP_CLOSE_CODES = [WEBSOCKET_CLOSE_CODES.SESSION_EXPIRED, WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT];
/**
* Communicate with a SharedWorker in order to provide a single websocket
* connection shared across multiple tabs.
*
* @emits connect
* @emits disconnect
* @emits reconnect
* @emits reconnecting
* @emits notification
*/
export const busService = {
dependencies: ['notification', 'presence', 'rpc', 'multi_tab'],
start(env, services) {
return new CrossTab(env, services);
dependencies: ['localization', 'notification'],
start(env) {
const bus = new EventBus();
const workerClass = 'SharedWorker' in window ? browser.SharedWorker : browser.Worker;
const worker = new workerClass('/bus/websocket_worker_bundle', {
name: 'SharedWorker' in window ? 'odoo:websocket_shared_worker' : 'odoo:websocket_worker',
});
let removeConnectionLostNotification;
/**
* Send a message to the worker.
*
* @param {WorkerAction} action Action to be
* executed by the worker.
* @param {Object|undefined} data Data required for the action to be
* executed.
*/
function send(action, data) {
const message = { action, data };
if ('SharedWorker' in window) {
worker.port.postMessage(message);
} else {
worker.postMessage(message);
}
}
/**
* Handle messages received from the shared worker and fires an
* event according to the message type.
*
* @param {MessageEvent} messageEv
* @param {{type: WorkerEvent, data: any}[]} messageEv.data
*/
function handleMessage(messageEv) {
const { type, data } = messageEv.data;
// Do not trigger the connection lost pop up if the reconnecting
// event is caused by a session expired/keep_alive_timeout.
if (type === 'reconnecting' && !NO_POPUP_CLOSE_CODES.includes(data.closeCode)) {
removeConnectionLostNotification = env.services.notification.add(
env._t("Websocket connection lost. Trying to reconnect..."),
{ sticky: true },
);
} else if (type === 'reconnect' && removeConnectionLostNotification) {
removeConnectionLostNotification();
removeConnectionLostNotification = null;
}
bus.trigger(type, data);
}
if ('SharedWorker' in window) {
worker.port.start();
worker.port.addEventListener('message', handleMessage);
} else {
worker.addEventListener('message', handleMessage);
}
browser.addEventListener('unload', () => send('leave'));
return {
addEventListener: bus.addEventListener.bind(bus),
addChannel: channel => send('add_channel', channel),
deleteChannel: channel => send('delete_channel', channel),
forceUpdateChannels: () => send('force_update_channels'),
trigger: bus.trigger.bind(bus),
removeEventListener: bus.removeEventListener.bind(bus),
send: (eventName, data) => send('send', { event_name: eventName, data }),
stop: () => send('leave'),
};
},
};
registry.category('services').add('bus_service', busService);
@@ -0,0 +1,301 @@
/** @odoo-module **/
import { debounce } from '@bus/workers/websocket_worker_utils';
/**
* Type of events that can be sent from the worker to its clients.
*
* @typedef { 'connect' | 'reconnect' | 'disconnect' | 'reconnecting' | 'notification' } WorkerEvent
*/
/**
* Type of action that can be sent from the client to the worker.
*
* @typedef {'add_channel' | 'delete_channel' | 'force_update_channels' | 'send' | 'leave' } WorkerAction
*/
export const WEBSOCKET_CLOSE_CODES = Object.freeze({
CLEAN: 1000,
GOING_AWAY: 1001,
PROTOCOL_ERROR: 1002,
INCORRECT_DATA: 1003,
ABNORMAL_CLOSURE: 1006,
INCONSISTENT_DATA: 1007,
MESSAGE_VIOLATING_POLICY: 1008,
MESSAGE_TOO_BIG: 1009,
EXTENSION_NEGOTIATION_FAILED: 1010,
SERVER_ERROR: 1011,
RESTART: 1012,
TRY_LATER: 1013,
BAD_GATEWAY: 1014,
SESSION_EXPIRED: 4001,
KEEP_ALIVE_TIMEOUT: 4002,
});
/**
* This class regroups the logic necessary in order for the
* SharedWorker/Worker to work. Indeed, Safari and some minor browsers
* do not support SharedWorker. In order to solve this issue, a Worker
* is used in this case. The logic is almost the same than the one used
* for SharedWorker and this class implements it.
*/
export class WebsocketWorker {
constructor(websocketURL) {
this.websocketURL = websocketURL;
this.channelsByClient = new Map();
this.connectRetryDelay = 0;
this.connectTimeout = null;
this.isReconnecting = false;
this.lastChannelSubscription = null;
this.lastNotificationId = 0;
this.messageWaitQueue = [];
this._forceUpdateChannels = debounce(this._forceUpdateChannels, 300, true);
this._start();
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Send the message to all the clients that are connected to the
* worker.
*
* @param {WorkerEvent} type Event to broadcast to connected
* clients.
* @param {Object} data
*/
broadcast(type, data) {
for (const client of this.channelsByClient.keys()) {
client.postMessage({ type, data });
}
}
/**
* Register a client handled by this worker.
*
* @param {MessagePort} messagePort
*/
registerClient(messagePort) {
messagePort.onmessage = ev => {
this._onClientMessage(messagePort, ev.data);
};
this.channelsByClient.set(messagePort, []);
this._updateChannels();
}
/**
* Send message to the given client.
*
* @param {number} client
* @param {WorkerEvent} type
* @param {Object} data
*/
sendToClient(client, type, data) {
client.postMessage({ type, data });
}
//--------------------------------------------------------------------------
// PRIVATE
//--------------------------------------------------------------------------
/**
* Called when a message is posted to the worker by a client (i.e. a
* MessagePort connected to this worker).
*
* @param {MessagePort} client
* @param {Object} message
* @param {WorkerAction} [message.action]
* Action to execute.
* @param {Object|undefined} [message.data] Data required by the
* action.
*/
_onClientMessage(client, { action, data }) {
switch (action) {
case 'send':
return this._sendToServer(data);
case 'leave':
return this._unregisterClient(client);
case 'add_channel':
return this._addChannel(client, data);
case 'delete_channel':
return this._deleteChannel(client, data);
case 'force_update_channels':
return this._forceUpdateChannels();
}
}
/**
* Add a channel for the given client. If this channel is not yet
* known, update the subscription on the server.
*
* @param {MessagePort} client
* @param {string} channel
*/
_addChannel(client, channel) {
const clientChannels = this.channelsByClient.get(client);
if (!clientChannels.includes(channel)) {
clientChannels.push(channel);
this.channelsByClient.set(client, clientChannels);
this._updateChannels();
}
}
/**
* Remove a channel for the given client. If this channel is not
* used anymore, update the subscription on the server.
*
* @param {MessagePort} client
* @param {string} channel
*/
_deleteChannel(client, channel) {
const clientChannels = this.channelsByClient.get(client);
if (!clientChannels) {
return;
}
const channelIndex = clientChannels.indexOf(channel);
if (channelIndex !== -1) {
clientChannels.splice(channelIndex, 1);
this._updateChannels();
}
}
/**
* Update the channels on the server side even if the channels on
* the client side are the same than the last time we subscribed.
*/
_forceUpdateChannels() {
this._updateChannels({ force: true });
}
/**
* Remove the given client from this worker client list as well as
* its channels. If some of its channels are not used anymore,
* update the subscription on the server.
*
* @param {MessagePort} client
*/
_unregisterClient(client) {
this.channelsByClient.delete(client);
this._updateChannels();
}
/**
* Determine whether or not the websocket associated to this worker
* is connected.
*
* @returns {boolean}
*/
_isWebsocketConnected() {
return this.websocket && this.websocket.readyState === 1;
}
/**
* Triggered when a connection is closed. If closure was not clean ,
* try to reconnect after indicating to the clients that the
* connection was closed.
*
* @param {CloseEvent} ev
* @param {number} code close code indicating why the connection
* was closed.
* @param {string} reason reason indicating why the connection was
* closed.
*/
_onWebsocketClose({ code, reason }) {
if (this.isReconnecting) {
// Connection was not established but the close event was
// triggered anyway. Let the onWebsocketError method handle
// this case.
return;
}
this.broadcast('disconnect', { code, reason });
if (code === WEBSOCKET_CLOSE_CODES.CLEAN) {
// WebSocket was closed on purpose, do not try to reconnect.
return;
}
// WebSocket was not closed cleanly, let's try to reconnect.
this.broadcast('reconnecting', { closeCode: code });
this.isReconnecting = true;
this._onWebsocketError();
}
/**
* Triggered when a connection failed or failed to established.
* Apply an exponential back off to the reconnect attempts.
*/
_onWebsocketError() {
this.connectRetryDelay = this.connectRetryDelay * 1.5 + 500 * Math.random();
this.connectTimeout = setTimeout(this._start.bind(this), this.connectRetryDelay);
}
/**
* Handle data received from the bus.
*
* @param {MessageEvent} messageEv
*/
_onWebsocketMessage(messageEv) {
const notifications = JSON.parse(messageEv.data);
this.lastNotificationId = notifications[notifications.length - 1].id;
this.broadcast('notification', notifications.map(notification => notification.message));
}
/**
* Triggered on websocket open. Send message that were waiting for
* the connection to open.
*/
_onWebsocketOpen() {
this.messageWaitQueue.forEach(msg => this.websocket.send(msg));
this.messageWaitQueue = [];
this.broadcast(this.isReconnecting ? 'reconnect' : 'connect');
if (this.isReconnecting) {
this._forceUpdateChannels();
}
this.connectRetryDelay = 0;
this.connectTimeout = null;
this.isReconnecting = false;
}
/**
* Send a message to the server through the websocket connection.
* If the websocket is not open, enqueue the message and send it
* upon the next reconnection.
*
* @param {any} message Message to send to the server.
*/
_sendToServer(message) {
const payload = JSON.stringify(message);
if (!this._isWebsocketConnected()) {
this.messageWaitQueue.push(payload);
} else {
this.websocket.send(payload);
}
}
/**
* Start the worker by opening a websocket connection.
*/
_start() {
this.websocket = new WebSocket(this.websocketURL);
this.websocket.addEventListener('open', this._onWebsocketOpen.bind(this));
this.websocket.addEventListener('error', this._onWebsocketError.bind(this));
this.websocket.addEventListener('message', this._onWebsocketMessage.bind(this));
this.websocket.addEventListener('close', this._onWebsocketClose.bind(this));
}
/**
* Update the channel subscription on the server. Ignore if the channels
* did not change since the last subscription.
*
* @param {boolean} force Whether or not we should update the subscription
* event if the channels haven't change since last subscription.
*/
_updateChannels({ force = false } = {}) {
const allTabsChannels = [...new Set([].concat.apply([], [...this.channelsByClient.values()]))].sort();
const allTabsChannelsString = JSON.stringify(allTabsChannels);
const shouldUpdateChannelSubscription = allTabsChannelsString !== this.lastChannelSubscription;
if (force || shouldUpdateChannelSubscription) {
this.lastChannelSubscription = allTabsChannelsString;
this._sendToServer({ event_name: 'subscribe', data: { channels: allTabsChannels, last: this.lastNotificationId } });
}
}
}
@@ -0,0 +1,25 @@
/** @odoo-module **/
/* eslint-env worker */
/* eslint-disable no-restricted-globals */
import { WebsocketWorker } from "./websocket_worker";
(function () {
const websocketWorker = new WebsocketWorker(
`${self.location.protocol === 'https:' ? 'wss' : 'ws'}://${self.location.host}/websocket`
);
if (self.name.includes('shared')) {
// The script is running in a shared worker: let's register every
// tab connection to the worker in order to relay notifications
// coming from the websocket.
onconnect = function (ev) {
const currentClient = ev.ports[0];
websocketWorker.registerClient(currentClient);
};
} else {
// The script is running in a simple web worker.
websocketWorker.registerClient(self);
}
})();
@@ -0,0 +1,29 @@
/** @odoo-module **/
/**
* Returns a function, that, as long as it continues to be invoked, will not
* be triggered. The function will be called after it stops being called for
* N milliseconds. If `immediate` is passed, trigger the function on the
* leading edge, instead of the trailing.
*
* Inspired by https://davidwalsh.name/javascript-debounce-function
*/
export function debounce(func, wait, immediate) {
let timeout;
return function () {
const context = this;
const args = arguments;
function later() {
timeout = null;
if (!immediate) {
func.apply(context, args);
}
}
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) {
func.apply(context, args);
}
};
}
+148 -91
View File
@@ -2,16 +2,18 @@ odoo.define('web.bus_tests', function (require) {
"use strict";
var { busService } = require('@bus/services/bus_service');
const { startServer } = require('@bus/../tests/helpers/mock_python_environment');
const { presenceService } = require('@bus/services/presence_service');
const { multiTabService } = require('@bus/multi_tab_service');
const { WEBSOCKET_CLOSE_CODES } = require("@bus/workers/websocket_worker");
const { startServer } = require('@bus/../tests/helpers/mock_python_environment');
const { patchWebsocketWorkerWithCleanup } = require("@bus/../tests/helpers/mock_websocket");
var { CrossTab } = require('@bus/crosstab_bus');
var testUtils = require('web.test_utils');
const { browser } = require("@web/core/browser/browser");
const { registry } = require("@web/core/registry");
const { patchWithCleanup, nextTick } = require("@web/../tests/helpers/utils");
const { makeTestEnv } = require('@web/../tests/helpers/mock_env');
const { createWebClient } = require("@web/../tests/webclient/helpers");
QUnit.module('Bus', {
beforeEach: function () {
@@ -29,7 +31,7 @@ QUnit.module('Bus', {
registry.category('services').add('multi_tab', customMultiTabService);
},
}, function () {
QUnit.test('notifications received from the longpolling channel', async function (assert) {
QUnit.test('notifications received from the channel', async function (assert) {
assert.expect(3);
const pyEnv = await startServer();
@@ -50,6 +52,37 @@ QUnit.module('Bus', {
]);
});
QUnit.test('notifications not received after stoping the service', async function (assert) {
assert.expect(4);
const pyEnv = await startServer();
const firstTabEnv = await makeTestEnv({ activateMockServer: true });
const secondTabEnv = await makeTestEnv({ activateMockServer: true });
firstTabEnv.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => {
assert.step('1 - notification - ' + notifications.map(notif => notif.payload).toString());
});
firstTabEnv.services['bus_service'].addChannel('lambda');
secondTabEnv.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => {
assert.step('2 - notification - ' + notifications.map(notif => notif.payload).toString());
});
// both tabs should receive the notification
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'beta');
await nextTick();
secondTabEnv.services['bus_service'].stop();
await nextTick();
// only first tab should receive the notification since the
// second tab has called the stop method.
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'epsilon');
await nextTick();
assert.verifySteps([
'1 - notification - beta',
'2 - notification - beta',
'1 - notification - epsilon',
]);
});
QUnit.test('notifications still received after disconnect/reconnect', async function (assert) {
assert.expect(3);
@@ -69,9 +102,12 @@ QUnit.module('Bus', {
});
env.services['bus_service'].addChannel('lambda');
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'beta');
pyEnv.simulateConnectionLostAndRecovered();
pyEnv.simulateConnectionLost(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE);
// Give websocket worker a tick to try to restart
await nextTick();
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'gamma');
// Give longpolling bus a tick to try to restart polling
// Give bus service a tick to receive the notification from
// postMessage.
await nextTick();
assert.verifySteps([
@@ -80,44 +116,6 @@ QUnit.module('Bus', {
]);
});
QUnit.test('provide notification ID of 0 by default', async function (assert) {
// This test is important in order to ensure that we provide the correct
// sentinel value 0 when we are not aware of the last notification ID
// that we have received. We cannot provide an ID of -1, otherwise it
// may likely be handled incorrectly (before this test was written,
// it was providing -1 to the server, which in return sent every stored
// notifications related to this user).
assert.expect(3);
// Simulate no ID of last notification in the local storage
patchWithCleanup(browser, {
getItem(key) {
if (key === 'last_ts') {
return 0;
}
return this._super(...arguments);
},
});
var pollPromise = testUtils.makeTestPromise();
const env = await makeTestEnv({
mockRPC(route, args) {
if (route === '/longpolling/poll') {
assert.step(route);
assert.strictEqual(args.last, 0,
"provided last notification ID should be 0");
pollPromise = testUtils.makeTestPromise();
return pollPromise;
}
}
});
// trigger longpolling poll RPC
env.services['bus_service'].addChannel('lambda');
assert.verifySteps(['/longpolling/poll']);
});
QUnit.test('tabs share message from a channel', async function (assert) {
assert.expect(3);
@@ -130,13 +128,8 @@ QUnit.module('Bus', {
mainEnv.services['bus_service'].addChannel('lambda');
// slave
const slaveEnv = await makeTestEnv({
mockRPC(route, args) {
if (route === '/longpolling/poll') {
throw new Error("Can not use the longpolling of the slave client");
}
}
});
const slaveEnv = await makeTestEnv();
slaveEnv.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => {
assert.step('slave - notification - ' + notifications.map(notif => notif.payload).toString());
});
@@ -151,44 +144,6 @@ QUnit.module('Bus', {
]);
});
QUnit.test('secondEnv start polling after main unload', async function (assert) {
assert.expect(3);
// main tab.
const mainEnv = await makeTestEnv({
mockRPC(route) {
if (route === '/longpolling/poll') {
assert.step('main - poll');
}
}
});
// prevent second tab from receiving unload event.
patchWithCleanup(browser, {
addEventListener(eventName, callback) {
if (eventName === 'unload') {
return;
}
this._super(eventName, callback);
},
});
// second tab
await makeTestEnv({
mockRPC(route) {
if (route === '/longpolling/poll') {
assert.step('second - poll');
}
}
});
mainEnv.services['bus_service'].startPolling();
// simulate main unload.
window.dispatchEvent(new Event('unload'));
assert.verifySteps([
'main - poll',
'second - poll',
]);
});
QUnit.test('second tab still receives notifications after main unload', async function (assert) {
assert.expect(4);
@@ -220,6 +175,7 @@ QUnit.module('Bus', {
// simulate unloading main
window.dispatchEvent(new Event('unload'));
await nextTick();
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'gamma');
await testUtils.nextTick();
@@ -234,7 +190,7 @@ QUnit.module('Bus', {
QUnit.test('two tabs calling addChannel simultaneously', async function (assert) {
assert.expect(5);
patchWithCleanup(CrossTab.prototype, {
const channelPatch = {
addChannel(channel) {
assert.step('Tab ' + this.__tabId__ + ': addChannel ' + channel);
this._super.apply(this, arguments);
@@ -243,12 +199,13 @@ QUnit.module('Bus', {
assert.step('Tab ' + this.__tabId__ + ': deleteChannel ' + channel);
this._super.apply(this, arguments);
},
});
};
const firstTabEnv = await makeTestEnv({ activateMockServer: true });
const secondTabEnv = await makeTestEnv({ activateMockServer: true });
firstTabEnv.services['bus_service'].__tabId__ = 1;
secondTabEnv.services['bus_service'].__tabId__ = 2;
patchWithCleanup(firstTabEnv.services['bus_service'], channelPatch);
patchWithCleanup(secondTabEnv.services['bus_service'], channelPatch);
firstTabEnv.services['bus_service'].addChannel('alpha');
secondTabEnv.services['bus_service'].addChannel('alpha');
firstTabEnv.services['bus_service'].addChannel('beta');
@@ -288,6 +245,106 @@ QUnit.module('Bus', {
'second - notification - alpha,beta',
]);
});
QUnit.test('channel management from multiple tabs', async function (assert) {
assert.expect(4);
patchWebsocketWorkerWithCleanup({
_sendToServer({ event_name, data }) {
assert.step(`${event_name} - [${data.channels.toString()}]`);
},
});
const firstTabEnv = await makeTestEnv();
const secTabEnv = await makeTestEnv();
firstTabEnv.services['bus_service'].addChannel('channel1');
await nextTick();
// this should not trigger a subscription since the channel1 was
// aleady known.
secTabEnv.services['bus_service'].addChannel('channel1');
await nextTick();
// removing channel1 from first tab should not trigger
// re-subscription since the second tab still listens to this
// channel.
firstTabEnv.services['bus_service'].deleteChannel('channel1');
await nextTick();
// this should trigger a subscription since the channel2 was not
// known.
secTabEnv.services['bus_service'].addChannel('channel2');
await nextTick();
assert.verifySteps([
'subscribe - []',
'subscribe - [channel1]',
'subscribe - [channel1,channel2]',
]);
});
QUnit.test('channels subscription after disconnection', async function (assert) {
// Patch setTimeout in order for the worker to reconnect immediatly.
patchWithCleanup(window, {
setTimeout: fn => fn(),
});
const worker = patchWebsocketWorkerWithCleanup({
_sendToServer({ event_name, data }) {
assert.step(`${event_name} - [${data.channels.toString()}]`);
},
});
await makeTestEnv();
// wait for the websocket to connect and the first subscription
// to occur.
await nextTick();
worker.websocket.close(WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT);
// wait for the websocket to re-connect.
await nextTick();
assert.verifySteps([
'subscribe - []',
'subscribe - []',
]);
});
QUnit.test('displays reconnect notification', async (assert) => {
assert.expect(3);
const pyEnv = await startServer();
await createWebClient({});
pyEnv.simulateConnectionLost(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE);
await nextTick();
assert.containsOnce(document.body, '.o_notification');
assert.strictEqual(
document.querySelector('.o_notification .o_notification_content').textContent,
'Websocket connection lost. Trying to reconnect...'
);
// Wait for the worker to reconnect, post a message and the
// bus_service to receive it and remove the notification.
const { afterNextRender } = owl.App;
await afterNextRender(() => {});
assert.containsNone(document.body, '.o_notification');
});
QUnit.test('does not display connection lost popup when refreshing the session', async (assert) => {
assert.expect(1);
const pyEnv = await startServer();
await createWebClient({});
pyEnv.simulateConnectionLost(WEBSOCKET_CLOSE_CODES.SESSION_EXPIRED);
await nextTick();
assert.containsNone(document.body, '.o_notification');
});
QUnit.test('does not display connection lost popup when refreshing the connection upon keep_alive_timeout', async (assert) => {
assert.expect(1);
const pyEnv = await startServer();
await createWebClient({});
pyEnv.simulateConnectionLost(WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT);
await nextTick();
assert.containsNone(document.body, '.o_notification');
});
});
});
@@ -113,8 +113,8 @@ let pyEnv;
getViews() {
return views;
},
simulateConnectionLostAndRecovered() {
this.mockServer._simulateConnectionLostAndRecovered();
simulateConnectionLost(closeCode) {
this.mockServer._simulateConnectionLost(closeCode);
},
...TEST_USER_IDS,
},
+13 -16
View File
@@ -1,6 +1,7 @@
/** @odoo-module **/
import { TEST_USER_IDS } from "@bus/../tests/helpers/test_constants";
import { patchWebsocketWorkerWithCleanup } from '@bus/../tests/helpers/mock_websocket';
import { patch } from "@web/core/utils/patch";
import { ConnectionLostError } from "@web/core/network/rpc_service";
@@ -11,6 +12,7 @@ patch(MockServer.prototype, 'bus', {
init() {
this._super(...arguments);
Object.assign(this, TEST_USER_IDS);
this.websocketWorker = patchWebsocketWorkerWithCleanup();
this.pendingLongpollingPromise = null;
this.notificationsToBeResolved = [];
},
@@ -57,26 +59,21 @@ patch(MockServer.prototype, 'bus', {
const values = [];
for (const notification of notifications) {
const [type, payload] = notification.slice(1, notification.length);
values.push({ message: { payload, type } });
}
if (this.pendingLongpollingPromise) {
this.pendingLongpollingPromise.resolve(values);
this.pendingLongpollingPromise = null;
} else {
this.notificationsToBeResolved.push(...values);
values.push({ payload, type });
}
this.websocketWorker.broadcast('notification', values);
},
/**
* Simulate the lost of the connection by rejecting the pending
* promise with ConnectionLostError. If there is no pending promise,
* reject the next one.
* Simulate the lost of the connection by simulating a closeEvent on
* the worker websocket.
*
* @param {number} clodeCode the code to close the connection with.
*/
_simulateConnectionLostAndRecovered() {
if (this.pendingLongpollingPromise) {
this.pendingLongpollingPromise.reject(new ConnectionLostError());
} else {
this.hasLostConnection = true;
}
_simulateConnectionLost(closeCode) {
this.websocketWorker.websocket.dispatchEvent(new CloseEvent('close', {
code: closeCode,
}));
},
});
@@ -0,0 +1,84 @@
/** @odoo-module **/
import { WebsocketWorker } from "@bus/workers/websocket_worker";
import { browser } from "@web/core/browser/browser";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
class WebSocketMock extends EventTarget {
constructor(url) {
super();
this.readyState = 0;
this.url = url;
queueMicrotask(() => {
this.readyState = 1;
const openEv = new Event('open');
this.onopen(openEv);
this.dispatchEvent(openEv);
});
}
close(code, reason) {
this.readyState = 3;
const closeEv = new CloseEvent('close', {
code,
reason,
wasClean: code === 1000,
});
this.onclose(closeEv);
this.dispatchEvent(closeEv);
}
onclose(closeEv) {}
onerror(errorEv) {}
onopen(openEv) {}
send(data) {
if (this.readyState !== 1) {
const errorEv = new Event('error');
this.onerror(errorEv);
this.dispatchEvent(errorEv);
throw new DOMException("Failed to execute 'send' on 'WebSocket': State is not OPEN");
}
}
}
class SharedWorkerMock {
constructor(websocketWorker) {
this._websocketWorker = websocketWorker;
this._messageChannel = new MessageChannel();
this.port = this._messageChannel.port1;
// port 1 should be started by the service itself.
this._messageChannel.port2.start();
this._websocketWorker.registerClient(this._messageChannel.port2);
}
}
let websocketWorker;
/**
* @param {*} params Parameters used to patch the websocket worker.
* @returns {WebsocketWorker} Instance of the worker which will run during the
* test. Usefull to interact with the worker in order to test the
* websocket behavior.
*/
export function patchWebsocketWorkerWithCleanup(params = {}) {
patchWithCleanup(window, {
WebSocket: function () {
return new WebSocketMock();
},
}, { pure: true });
patchWithCleanup(websocketWorker || WebsocketWorker.prototype, params);
websocketWorker = websocketWorker || new WebsocketWorker('wss://odoo.com/websocket');
patchWithCleanup(browser, {
SharedWorker: function () {
return new SharedWorkerMock(websocketWorker);
},
}, { pure: true });
registerCleanup(() => {
clearTimeout(websocketWorker.connectTimeout);
websocketWorker = null;
});
return websocketWorker;
}
@@ -0,0 +1,98 @@
/** @odoo-module */
import { WEBSOCKET_CLOSE_CODES } from "@bus/workers/websocket_worker";
import { patchWebsocketWorkerWithCleanup } from '@bus/../tests/helpers/mock_websocket';
import { nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
QUnit.module('Websocket Worker');
QUnit.test('connect event is broadcasted', async function (assert) {
assert.expect(2);
patchWebsocketWorkerWithCleanup({
broadcast(type) {
assert.step(`broadcast ${type}`);
},
});
// Wait for the websocket to connect.
await nextTick();
assert.verifySteps(['broadcast connect']);
});
QUnit.test('disconnect event is broadcasted', async function (assert) {
assert.expect(3);
const worker = patchWebsocketWorkerWithCleanup({
broadcast(type) {
assert.step(`broadcast ${type}`);
},
});
// Wait for the websocket to connect.
await nextTick();
worker.websocket.close(WEBSOCKET_CLOSE_CODES.CLEAN);
// Wait for the websocket to disconnect.
await nextTick();
assert.verifySteps([
'broadcast connect',
'broadcast disconnect',
]);
});
QUnit.test('reconnecting/reconnect event is broadcasted', async function (assert) {
assert.expect(5);
// Patch setTimeout in order for the worker to reconnect immediatly.
patchWithCleanup(window, {
setTimeout: fn => fn(),
});
const worker = patchWebsocketWorkerWithCleanup({
broadcast(type) {
assert.step(`broadcast ${type}`);
},
});
// Wait for the websocket to connect.
await nextTick();
worker.websocket.close(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE);
// Wait for the disconnect/reconnecting/reconnect events.
await nextTick();
assert.verifySteps([
'broadcast connect',
'broadcast disconnect',
'broadcast reconnecting',
'broadcast reconnect',
]);
});
QUnit.test('notification event is broadcasted', async function (assert) {
assert.expect(3);
const notifications = [{
id: 70,
message: {
type: "bundle_changed",
payload: {
server_version: '15.5alpha1+e',
},
},
}];
const worker = patchWebsocketWorkerWithCleanup({
broadcast(type, message) {
if (type === 'notification') {
assert.step(`broadcast ${type}`);
assert.deepEqual(message, notifications.map(notif => notif.message));
}
},
});
// Wait for the websocket to connect.
await nextTick();
worker.websocket.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify(notifications),
}));
assert.verifySteps([
'broadcast notification',
]);
});
@@ -19,7 +19,6 @@ export const calendarNotificationService = {
}
}
});
bus_service.startPolling();
/**
* Displays the Calendar notification on user's screen
@@ -40,8 +40,8 @@ var KioskMode = AbstractAction.extend({
},
on_attach_callback: function () {
// Stop polling to avoid notifications in kiosk mode
this.call('bus_service', 'stopPolling');
// Stop the bus_service to avoid notifications in kiosk mode
this.call('bus_service', 'stop');
$('body').find('.o_ChatWindowHeader_commandClose').click();
},
@@ -18,7 +18,6 @@ export const iapNotificationService = {
}
}
});
bus_service.startPolling();
/**
* Displays the IAP success notification on user's screen
+5 -5
View File
@@ -177,10 +177,12 @@ Help your customers with this chat, and analyse their feedback.
'web/static/src/legacy/js/services/ajax_service.js',
'web/static/src/legacy/js/services/local_storage_service.js',
# Bus, Mail, Livechat
'bus/static/src/longpolling_bus.js',
'bus/static/src/crosstab_bus.js',
'bus/static/src/services/bus_service.js',
'bus/static/src/im_status_service.js',
'bus/static/src/multi_tab_service.js',
'bus/static/src/services/bus_service.js',
'bus/static/src/services/legacy/make_bus_service_to_legacy_env.js',
'bus/static/src/workers/websocket_worker.js',
'bus/static/src/workers/websocket_worker_utils.js',
'mail/static/src/js/utils.js',
'im_livechat/static/src/legacy/public_livechat_chatbot.js',
@@ -202,9 +204,7 @@ Help your customers with this chat, and analyse their feedback.
'mail/static/src/services/messaging_service.js',
# Framework JS
'bus/static/src/*.js',
'bus/static/src/services/bus_service.js',
'bus/static/src/services/presence_service.js',
'bus/static/src/services/legacy/make_bus_service_to_legacy_env.js',
'web/static/lib/luxon/luxon.js',
'web/static/src/core/**/*',
# FIXME: debug menu currently depends on webclient, once it doesn't we don't need to remove the contents of the debug folder
@@ -12,7 +12,6 @@ registerModel({
lifecycleHooks: {
_created() {
this.env.services['bus_service'].addChannel(this.messaging.publicLivechatGlobal.publicLivechat.uuid);
this.env.services['bus_service'].startPolling();
this.env.services['bus_service'].addEventListener('notification', this._onNotification);
},
},
-5
View File
@@ -152,11 +152,6 @@ For more specific needs, you may also assign custom-defined actions
('remove', 'mail/static/src/services/systray_service.js'),
'mail/static/src/utils/*.js',
# Framework JS
'bus/static/src/*.js',
'bus/static/src/services/bus_service.js',
'bus/static/src/services/presence_service.js',
'bus/static/src/multi_tab_service.js',
'bus/static/src/services/legacy/make_bus_service_to_legacy_env.js',
'web/static/lib/luxon/luxon.js',
'web/static/src/core/**/*',
# FIXME: debug menu currently depends on webclient, once it doesn't we don't need to remove the contents of the debug folder
+1 -1
View File
@@ -133,7 +133,7 @@ registerModel({
},
/**
* Mark provided messages as read. Messages that have been marked as
* read are acknowledged by server with response as longpolling
* read are acknowledged by server with response as bus.
* notification of following format:
*
* [[dbname, 'res.partner', partnerId], { type: 'mark_as_read' }]
+19 -1
View File
@@ -221,7 +221,7 @@ registerModel({
}
if (partnerIds.length !== 0 || guestIds.length !== 0) {
const dataList = await this.messaging.rpc({
route: '/longpolling/im_status',
route: '/bus/im_status',
params: {
partner_ids: partnerIds,
guest_ids: guestIds,
@@ -310,6 +310,14 @@ registerModel({
_computeNotificationHandler() {
return {};
},
/**
* @private
*/
_onChangeAllCurrentClientThreads() {
if (this.isInitialized) {
this.env.services.bus_service.forceUpdateChannels();
}
},
/**
* @private
*/
@@ -333,6 +341,12 @@ registerModel({
inverse: 'messaging',
isCausal: true,
}),
/**
* This field contains all current client channels.
*/
allCurrentClientThreads: many('Thread', {
inverse: 'messagingAsAllCurrentClientThreads',
}),
browser: attr({
compute: '_computeBrowser',
}),
@@ -513,5 +527,9 @@ registerModel({
dependencies: ['ringingThreads'],
methodName: '_onChangeRingingThreads',
},
{
dependencies: ['allCurrentClientThreads'],
methodName: '_onChangeAllCurrentClientThreads',
},
],
});
@@ -16,7 +16,6 @@ registerModel({
lifecycleHooks: {
_willDelete() {
this.env.services['bus_service'].removeEventListener('notification', this._handleNotifications);
this.env.services['bus_service'].stopPolling();
},
},
recordMethods: {
@@ -26,7 +25,6 @@ registerModel({
*/
start() {
this.env.services['bus_service'].addEventListener('notification', this._handleNotifications);
this.env.services['bus_service'].startPolling();
},
/**
* @private
+23
View File
@@ -1430,6 +1430,25 @@ registerModel({
return total + 1;
}, baseCounter);
},
/**
* @private
* @return {FieldCommand}
*/
_computeMessagingAsAllCurrentClientThreads() {
// The current client is linked to this thread if any of
// those conditions is met:
// - `isServerPinned` is true.
// - `messaging.currentPartner` is set and this partner is
// a member of this thread.
// - `messaging.currentGuest` is set and this guest is a
// guest member of this thread.
const isPartnerMember = this.members.includes(this.messaging.currentPartner);
const isGuestMember = this.guestMembers.includes(this.messaging.currentGuest);
if (!this.messaging || !isPartnerMember && !isGuestMember && !this.isServerPinned) {
return clear();
}
return this.messaging;
},
/**
* @private
* @returns {FieldCommand}
@@ -2129,6 +2148,10 @@ registerModel({
inverse: 'thread',
isCausal: true,
}),
messagingAsAllCurrentClientThreads: one('Messaging', {
compute: '_computeMessagingAsAllCurrentClientThreads',
inverse: 'allCurrentClientThreads',
}),
messagingAsRingingThread: one('Messaging', {
compute: '_computeMessagingAsRingingThread',
inverse: 'ringingThreads',
@@ -270,7 +270,7 @@ registerModel({
* cache (@see messages field for that): it just contains list
* of successive messages that have been explicitly fetched by this
* cache. For all non-main caches, this corresponds to all messages.
* For the main cache, however, messages received from longpolling
* For the main cache, however, messages received from the bus
* should be displayed on main cache but they have not been explicitly
* fetched by cache, so they ARE NOT in this list (at least, not until a
* fetch on this thread cache contains this message).
@@ -62,7 +62,7 @@ patch(MockServer.prototype, 'mail', {
*/
async _performRPC(route, args) {
// routes
if (route === '/longpolling/im_status') {
if (route === '/bus/im_status') {
const { partner_ids } = args;
return {
'partners': this.pyEnv['res.partner'].searchRead([['id', 'in', partner_ids]], { context: { 'active_test': false }, fields: ['im_status'] })
@@ -2,8 +2,8 @@
import { busService } from '@bus/services/bus_service';
import { multiTabService } from '@bus/multi_tab_service';
import { makeBusServiceToLegacyEnv } from '@bus/services/legacy/make_bus_service_to_legacy_env';
import { makeMultiTabToLegacyEnv } from '@bus/services/legacy/make_multi_tab_to_legacy_env';
import { makeBusServiceToLegacyEnv } from '@bus/services/legacy/make_bus_service_to_legacy_env';
import { makeFakePresenceService } from '@bus/../tests/helpers/mock_services';
import { ChatWindowManagerContainer } from '@mail/components/chat_window_manager_container/chat_window_manager_container';
@@ -19,7 +19,6 @@ import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { createWebClient } from "@web/../tests/webclient/helpers";
const ROUTES_TO_IGNORE = [
'/longpolling/poll',
'/web/webclient/load_menus',
'/web/dataset/call_kw/res.users/load_views',
'/web/dataset/call_kw/res.users/systray_get_activities'
@@ -86,20 +85,8 @@ function setupMessagingServiceRegistries({
}
};
const customBusService = {
...busService,
start() {
const originalService = busService.start(...arguments);
Object.assign(originalService, {
_registerWindowUnload() {}, // Do nothing
updateOption() {},
});
return originalService;
},
};
services = {
bus_service: customBusService,
bus_service: busService,
messaging: messagingService,
messagingValues,
presence: makeFakePresenceService({
@@ -21,7 +21,7 @@ QUnit.test('list activity widget with no activity', async function (assert) {
mockRPC: function (route, args) {
if (
args.method !== 'get_views' &&
!['/mail/init_messaging', '/mail/load_message_failures', '/longpolling/im_status', ...ROUTES_TO_IGNORE].includes(route)
!['/mail/init_messaging', '/mail/load_message_failures', '/bus/im_status', ...ROUTES_TO_IGNORE].includes(route)
) {
assert.step(route);
}
@@ -70,7 +70,7 @@ QUnit.test('list activity widget with activities', async function (assert) {
mockRPC: function (route, args) {
if (
args.method !== 'get_views' &&
!['/mail/init_messaging', '/mail/load_message_failures', '/longpolling/im_status', ...ROUTES_TO_IGNORE].includes(route)
!['/mail/init_messaging', '/mail/load_message_failures', '/bus/im_status', ...ROUTES_TO_IGNORE].includes(route)
) {
assert.step(route);
}
@@ -115,7 +115,7 @@ QUnit.test('list activity widget with exception', async function (assert) {
mockRPC: function (route, args) {
if (
args.method !== 'get_views' &&
!['/mail/init_messaging', '/mail/load_message_failures', '/longpolling/im_status', ...ROUTES_TO_IGNORE].includes(route)
!['/mail/init_messaging', '/mail/load_message_failures', '/bus/im_status', ...ROUTES_TO_IGNORE].includes(route)
) {
assert.step(route);
}
@@ -172,7 +172,7 @@ QUnit.test('list activity widget: open dropdown', async function (assert) {
mockRPC: function (route, args) {
if (
args.method !== 'get_views' &&
!['/mail/init_messaging', '/mail/load_message_failures', '/longpolling/im_status', ...ROUTES_TO_IGNORE].includes(route)
!['/mail/init_messaging', '/mail/load_message_failures', '/bus/im_status', ...ROUTES_TO_IGNORE].includes(route)
) {
assert.step(args.method || route);
}
-1
View File
@@ -111,7 +111,6 @@
('include', 'web.assets_backend'),
# Remove Longpolling bus and packages needed this bus
('remove', 'bus/static/src/services/assets_watchdog_service.js'),
('remove', 'mail/static/src/services/messaging_service.js'),
('remove', 'mail/static/src/components/dialog_manager/dialog_manager.js'),
@@ -11,7 +11,7 @@ addRecordMethods('Message', {
* @returns {Deferred}
*/
async cancelLetter() {
// the result will come from longpolling: message_notification_update
// the result will come from the bus: message_notification_update
await this.messaging.rpc({
model: 'mail.message',
method: 'cancel_letter',
@@ -53,7 +53,7 @@ addRecordMethods('Message', {
* Retries to send the 'snailmail.letter' corresponding to this message.
*/
async resendLetter() {
// the result will come from longpolling: message_notification_update
// the result will come from the bus: message_notification_update
await this.messaging.rpc({
model: 'mail.message',
method: 'send_letter',
@@ -956,7 +956,6 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa
var self = this;
if (this.options.surveyToken && this.options.sessionInProgress) {
this.call('bus_service', 'addChannel', this.options.surveyToken);
this.call('bus_service', 'startPolling');
if (!this._checkisOnMainTab()) {
this.shouldReloadMasterTab = true;
+6 -7
View File
@@ -37,11 +37,10 @@
var commentRegExp = /(\/\*([\s\S]*?)\*\/|([^:]|^)\/\/(.*)$)/gm;
var cjsRequireRegExp = /[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g;
if (!window.odoo) {
window.odoo = {};
if (!globalThis.odoo) {
globalThis.odoo = {};
}
var odoo = window.odoo;
var odoo = globalThis.odoo;
var debug = odoo.debug;
var didLogInfoResolve;
@@ -223,9 +222,9 @@
});
if (debug || failed.length || unloaded.length) {
var log = window.console[
var log = globalThis.console[
!failed.length || !unloaded.length ? "info" : "error"
].bind(window.console);
].bind(globalThis.console);
log(
(failed.length ? "error" : unloaded.length ? "warning" : "info") +
": Some modules could not be started"
@@ -360,7 +359,7 @@
};
// Automatically log errors detected when loading modules
window.addEventListener("load", function logWhenLoaded() {
globalThis.addEventListener("load", function logWhenLoaded() {
const len = jobPromises.length;
Promise.all(jobPromises).then(function () {
if (len === jobPromises.length) {
@@ -34,6 +34,8 @@ export const browser = {
navigator: navigator,
Notification: window.Notification,
open: window.open.bind(window),
SharedWorker: window.SharedWorker,
Worker: window.Worker,
XMLHttpRequest: window.XMLHttpRequest,
localStorage,
sessionStorage,
+1 -1
View File
@@ -9,7 +9,7 @@ Odoo Web Editor widget.
==========================
""",
'depends': ['web'],
'depends': ['bus', 'web'],
'data': [
'security/ir.model.access.csv',
'data/editor_assets.xml',
@@ -397,7 +397,6 @@ const Wysiwyg = Widget.extend({
}
});
this.call('bus_service', 'addChannel', this._collaborationChannelName);
this.call('bus_service', 'startPolling');
// const syncHistory = async (fromClientId) => {
// }
@@ -51,7 +51,6 @@ patchRecordMethods('LivechatButtonView', {
this.widget._sendWelcomeMessage();
this.widget._renderMessages();
this.env.services.bus_service.addChannel(this.messaging.publicLivechatGlobal.publicLivechat.uuid);
this.env.services.bus_service.startPolling();
set_cookie('im_livechat_session', unaccent(JSON.stringify(this.messaging.publicLivechatGlobal.publicLivechat.legacyPublicLivechat.toData()), true), 60 * 60);
this.update({ isOpeningChat: false });
},