[FIX] web_editor: reset content when resuming collobartion idleness

Before this commit, when:
- client A close it's laptop temporarily, client B
- client B save another content
- client A re-open it's laptop
then, client A still have the outdated record version.

Now, if client A reconnect and the record has been modified, the
editor gets reset with the updated record.

Task-2745104

closes odoo/odoo#86149

X-original-commit: 1070d37a6c02c8a873336f4533d1e319a8762405
Signed-off-by: David Monjoie (dmo) <dmo@odoo.com>
This commit is contained in:
Nicolas Bayet
2022-03-10 09:00:13 +00:00
parent 0e37791e8b
commit f126f0c95b
4 changed files with 236 additions and 30 deletions
@@ -1062,6 +1062,9 @@ export class OdooEditor extends EventTarget {
this.historyApply(stepToApply.mutations);
}
}
collaborationSetClientId(id) {
this._collabClientId = id;
}
onExternalHistorySteps(newSteps) {
this.observerUnactive();
@@ -100,7 +100,8 @@ var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, {
}
var _super = this._super.bind(this);
this.wysiwyg.odooEditor.clean();
return this.wysiwyg.saveModifiedImages(this.$content).then(() => {
return this.wysiwyg.saveModifiedImages(this.$content).then(async () => {
await this.wysiwyg.preSavePromise;
this._isDirty = this.wysiwyg.isDirty();
_super();
});
@@ -169,6 +169,12 @@ export class PeerToPeer {
this.clientsInfos = {};
this._lastRequestId = -1;
this._pendingRequestResolver = {};
this._stopped = false;
}
stop() {
this._stopped = true;
this.closeAllConnections();
}
getConnectedClientIds() {
@@ -193,7 +199,7 @@ export class PeerToPeer {
delete this.clientsInfos[clientId];
}
async closeAllConnections() {
closeAllConnections() {
for (const clientId of Object.keys(this.clientsInfos)) {
this.notifyAllClients('ptp_disconnect');
this.removeClient(clientId);
@@ -201,6 +207,9 @@ export class PeerToPeer {
}
async notifyAllClients(notificationName, notificationPayload, { transport = 'server' } = {}) {
if (this._stopped) {
return;
}
const transportPayload = {
fromClientId: this._currentClientId,
notificationName,
@@ -220,6 +229,9 @@ export class PeerToPeer {
}
notifyClient(clientId, notificationName, notificationPayload, { transport = 'server' } = {}) {
if (this._stopped) {
return;
}
if (debugShowNotifications) {
if (notificationName === 'ptp_request_result') {
console.log(
@@ -267,10 +279,16 @@ export class PeerToPeer {
}
notifySelf(notificationName, notificationPayload) {
if (this._stopped) {
return;
}
this.handleNotification({ notificationName, notificationPayload });
}
handleNotification(notification) {
if (this._stopped) {
return;
}
const isInternalNotification =
typeof notification.fromClientId === 'undefined' &&
typeof notification.toClientId === 'undefined';
@@ -323,6 +341,9 @@ export class PeerToPeer {
}
requestClient(clientId, requestName, requestPayload, { transport = 'server' } = {}) {
if (this._stopped) {
return;
}
return new Promise((resolve, reject) => {
const requestId = this._getRequestId();
@@ -351,6 +372,9 @@ export class PeerToPeer {
}
_createClient(clientId, { makeOffer = true } = {}) {
if (this._stopped) {
return;
}
if (debugShowLog) console.log('CREATE CONNECTION with client id:', clientId);
this.clientsInfos[clientId] = {
makingOffer: false,
@@ -472,6 +496,9 @@ export class PeerToPeer {
}
_channelNotify(clientId, transportPayload) {
if (this._stopped) {
return;
}
const clientInfo = this.clientsInfos[clientId];
const dataChannel = clientInfo && clientInfo.dataChannel;
@@ -503,6 +530,9 @@ export class PeerToPeer {
}
async _onRequest(fromClientId, requestId, requestName, requestPayload, requestTransport) {
if (this._stopped) {
return;
}
const requestFunction = this.options.onRequest && this.options.onRequest[requestName];
const result = await requestFunction({
fromClientId,
@@ -527,6 +557,10 @@ export class PeerToPeer {
* @param {string} [param1.reason]
*/
_recoverConnection(clientId, { delay = 0, reason = '' } = {}) {
if (this._stopped) {
this.removeClient(clientId);
return;
}
const clientInfos = this.clientsInfos[clientId];
if (!clientInfos || clientInfos.fallbackTimeout) return;
@@ -552,6 +586,10 @@ export class PeerToPeer {
// todo: do we try to salvage the connection after killing the zombie ?
// Maybe the salvage should be done when the connection is dropped.
_killPotentialZombie(clientId) {
if (this._stopped) {
this.removeClient(clientId);
return;
}
const clientInfos = this.clientsInfos[clientId];
if (!clientInfos || clientInfos.zombieTimeout) {
return;
@@ -34,6 +34,24 @@ var id = 0;
const faZoomClassRegex = RegExp('fa-[0-9]x');
const mediaSelector = 'img, .fa, .o_image, .media_iframe_video';
// Time to consider a user offline in ms. This fixes the problem of the
// navigator closing rtc connection when the mac laptop screen is closed.
const CONSIDER_OFFLINE_TIME = 1000;
// Check wether the computer could be offline. This fixes the problem of the
// navigator closing rtc connection when the mac laptop screen is closed.
// This case happens on Mac OS on every browser when the user close it's laptop
// screen. At first, the os/navigator closes all rtc connection, and after some
// times, the os/navigator internet goes offline without triggering an
// offline/online event.
// However, if the laptop screen is open and the connection is properly remove
// (e.g. disconnect wifi), the event is properly triggered.
const CHECK_OFFLINE_TIME = 1000;
const PTP_CLIENT_DISCONNECTED_STATES = [
'failed',
'closed',
'disconnected',
];
const Wysiwyg = Widget.extend({
xmlDependencies: [
],
@@ -57,6 +75,10 @@ const Wysiwyg = Widget.extend({
this._onDocumentMousedown = this._onDocumentMousedown.bind(this);
this._onBlur = this._onBlur.bind(this);
this.customizableLinksSelector = 'a:not([data-toggle="tab"]):not([data-toggle="collapse"])';
// navigator.onLine is sometimes a false positive, this._isOnline use
// more heuristics to bypass the limitation.
this._isOnline = true;
this._signalOnline = this._signalOnline.bind(this);
},
/**
*
@@ -136,6 +158,14 @@ const Wysiwyg = Widget.extend({
self.$editable.trigger('content_changed');
self.trigger_up('wysiwyg_change');
});
document.addEventListener("mousemove", this._signalOnline, true);
document.addEventListener("keydown", this._signalOnline, true);
document.addEventListener("keyup", this._signalOnline, true);
if (this.odooEditor.document !== document) {
this.odooEditor.document.addEventListener("mousemove", this._signalOnline, true);
this.odooEditor.document.addEventListener("keydown", this._signalOnline, true);
this.odooEditor.document.addEventListener("keyup", this._signalOnline, true);
}
const $wrapwrap = $('#wrapwrap');
if ($wrapwrap.length) {
@@ -308,11 +338,6 @@ const Wysiwyg = Widget.extend({
return;
}
const startCollaborationTime = new Date().getTime();
// No need for secure random number.
const currentClientId = Math.floor(Math.random() * Math.pow(2, 52)).toString();
this._collaborationChannelName = channelName;
Wysiwyg.activeCollaborationChannelNames.add(channelName);
@@ -331,9 +356,6 @@ const Wysiwyg = Widget.extend({
this.call('bus_service', 'addChannel', this._collaborationChannelName);
this.call('bus_service', 'startPolling');
// Wether or not the history has been sent or received at least once.
let historySyncAtLeastOnce = false;
let historySyncFinished = false;
// const syncHistory = async (fromClientId) => {
// }
// Check wether clientA is before clientB.
@@ -346,22 +368,31 @@ const Wysiwyg = Widget.extend({
};
const rpcMutex = new Mutex();
this._peerToPeerLoading = new Promise(async (resolve) => {
let iceServers = await this._rpc({route: '/web_editor/get_ice_servers'});
if (!iceServers.length) {
iceServers = [
{
urls: [
'stun:stun1.l.google.com:19302',
'stun:stun2.l.google.com:19302',
],
}
];
}
this._getCurrentRecord = async () => {
const records = await this._rpc({
model: modelName,
method: "read",
args: [
[resId],
[fieldName, 'write_date']
],
});
records[0].body = records[0][fieldName];
return records[0];
}
this._getRecordWriteDate = (record) => {
const dateString = record.write_date.replace(/^(\d{4}-\d{2}-\d{2}) ((\d{2}:?){3})$/, '$1T$2Z');
return new Date(dateString);
}
this.ptp = new PeerToPeer({
peerConnectionConfig: { iceServers },
currentClientId: currentClientId,
this._getNewPtp = () => {
// Wether or not the history has been sent or received at least once.
let historySyncAtLeastOnce = false;
let historySyncFinished = false;
return new PeerToPeer({
peerConnectionConfig: { iceServers: this._iceServers },
currentClientId: this._currentClientId,
broadcastAll: (rpcData) => {
return rpcMutex.exec(async () => {
return this._rpc({
@@ -376,7 +407,7 @@ const Wysiwyg = Widget.extend({
});
},
onRequest: {
get_start_time: () => startCollaborationTime,
get_start_time: () => this._startCollaborationTime,
get_client_name: async () => {
if (!this._userName) {
this._userName = (await this._rpc({
@@ -399,6 +430,12 @@ const Wysiwyg = Widget.extend({
case 'ptp_remove':
this.odooEditor.multiselectionRemove(notificationPayload);
break;
case 'rtc_signal_description':
const pc = this.ptp.clientsInfos[fromClientId].peerConnection;
if (this._couldBeDisconnected && this._navigatorCheckOnlineWorking && (!pc || pc.connectionState === 'closed')) {
this._signalOnline();
}
break;
case 'ptp_disconnect':
this.ptp.removeClient(fromClientId);
this.odooEditor.multiselectionRemove(fromClientId);
@@ -442,11 +479,65 @@ const Wysiwyg = Widget.extend({
}
}
});
}
this._currentClientId = this._generateClientId();
this._startCollaborationTime = new Date().getTime();
this._checkConnectionChange = () => {
this._navigatorCheckOnlineWorking = true;
if (!this.ptp) {
return;
}
if (!navigator.onLine) {
this._signalOffline();
} else {
this._signalOnline();
}
};
window.addEventListener('online', this._checkConnectionChange);
window.addEventListener('offline', this._checkConnectionChange);
this._collaborationInterval = setInterval(async () => {
if (this._offlineTimeout || this.preSavePromise || !this.ptp) {
return;
}
const clientsInfos = Object.values(this.ptp.clientsInfos);
const couldBeDisconnected =
Boolean(clientsInfos.length) &&
clientsInfos.every((x) => PTP_CLIENT_DISCONNECTED_STATES.includes(x.peerConnection.connectionState));
if (couldBeDisconnected) {
this._offlineTimeout = setTimeout(() => {
this._signalOffline();
}, CONSIDER_OFFLINE_TIME);
}
}, CHECK_OFFLINE_TIME);
this._peerToPeerLoading = new Promise(async (resolve) => {
this._currentRecordWriteDate = this._getRecordWriteDate(await this._getCurrentRecord());
let iceServers = await this._rpc({route: '/web_editor/get_ice_servers'});
if (!iceServers.length) {
iceServers = [
{
urls: [
'stun:stun1.l.google.com:19302',
'stun:stun2.l.google.com:19302',
],
}
];
}
this._iceServers = iceServers;
this.ptp = this._getNewPtp();
resolve();
});
const editorCollaborationOptions = {
collaborationClientId: currentClientId,
collaborationClientId: this._currentClientId,
onHistoryStep: (historyStep) => {
if (!this.ptp) return;
this.ptp.notifyAllClients('oe_history_step', historyStep, { transport: 'rtc' });
@@ -474,8 +565,8 @@ const Wysiwyg = Widget.extend({
},
onHistoryNeedSync: async () => {
if (!this.ptp) return;
let firstClientId = currentClientId;
let firstClientStartTime = startCollaborationTime;
let firstClientId = this._currentClientId;
let firstClientStartTime = this._startCollaborationTime;
const connectedClientIds = this.ptp.getConnectedClientIds();
for (const clientId of connectedClientIds) {
const clientInfo = this.ptp.clientsInfos[clientId];
@@ -501,7 +592,7 @@ const Wysiwyg = Widget.extend({
}
}
if (firstClientId !== currentClientId) {
if (firstClientId !== this._currentClientId) {
const historySteps = await this.ptp.requestClient(firstClientId, 'get_history_from_snapshot', undefined, { transport: 'rtc' });
this.odooEditor.historyResetFromSteps(historySteps);
const remoteSelection = await this.ptp.requestClient(firstClientId, 'get_collaborative_selection', undefined, { transport: 'rtc' });
@@ -521,7 +612,16 @@ const Wysiwyg = Widget.extend({
Wysiwyg.activeCollaborationChannelNames.delete(this._collaborationChannelName);
}
if (this.ptp) {
this.ptp.stop();
}
document.removeEventListener("mousemove", this._signalOnline, true);
document.removeEventListener("keydown", this._signalOnline, true);
document.removeEventListener("keyup", this._signalOnline, true);
if (this.odooEditor) {
this.odooEditor.document.removeEventListener("mousemove", this._signalOnline, true);
this.odooEditor.document.removeEventListener("keydown", this._signalOnline, true);
this.odooEditor.document.removeEventListener("keyup", this._signalOnline, true);
this.odooEditor.document.removeEventListener('selectionchange', this._onSelectionChange);
this.odooEditor.destroy();
}
@@ -532,6 +632,7 @@ const Wysiwyg = Widget.extend({
this.ptp.closeAllConnections();
});
}
clearInterval(this._collaborationInterval);
this.$editable && this.$editable.off('blur', this._onBlur);
document.removeEventListener('mousedown', this._onDocumentMousedown, true);
const $body = $(document.body);
@@ -545,6 +646,10 @@ const Wysiwyg = Widget.extend({
if (this.linkPopover) {
this.linkPopover.hide();
}
if (this._checkConnectionChange) {
window.removeEventListener('online', this._checkConnectionChange);
window.removeEventListener('offline', this._checkConnectionChange);
}
window.removeEventListener('beforeunload', this._onBeforeUnload);
this._super();
},
@@ -1865,6 +1970,65 @@ const Wysiwyg = Widget.extend({
this.trigger_up('wysiwyg_blur');
}
},
_signalOffline: function () {
if (!this._isOnline) {
return;
}
this._isOnline = false;
this.ptp.stop();
this.preSavePromise = new Promise((resolve, reject) => {
this.preSavePromiseResolve = resolve;
this.preSavePromiseReject = reject;
});
},
_signalOnline: async function () {
clearTimeout(this._offlineTimeout);
this._offlineTimeout = undefined;
if (this._isOnline || !this.preSavePromise || !navigator.onLine) {
return;
}
this._isOnline = true;
if (this._removeSignalDisconnectCallback) {
this._removeSignalDisconnectCallback();
}
const resetPreSavePromise = () => {
this.preSavePromise = undefined;
this.preSavePromiseResolve = undefined;
this.preSavePromiseReject = undefined;
}
try {
const record = await this._getCurrentRecord();
const newDate = this._getRecordWriteDate(record);
if (newDate !== this._currentRecordWriteDate) {
this._resetEditor(record.body);
}
this.preSavePromiseResolve();
resetPreSavePromise();
} catch (e) {
this.preSavePromiseReject(e);
resetPreSavePromise();
}
},
_generateClientId: function () {
// No need for secure random number.
return Math.floor(Math.random() * Math.pow(2, 52)).toString();
},
_resetEditor: function (value) {
if (!this.ptp) {
return;
}
this.ptp.stop();
this._currentClientId = this._generateClientId();
this._startCollaborationTime = new Date().getTime();
this.ptp = this._getNewPtp();
this.odooEditor.collaborationSetClientId(this._currentClientId);
this.setValue(value);
this.odooEditor.historyReset();
this.ptp.notifyAllClients('ptp_join');
}
});
Wysiwyg.activeCollaborationChannelNames = new Set();
//--------------------------------------------------------------------------