[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:
@@ -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();
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user