From 009cd3124370d44a9c567332bf3be63826fd95b4 Mon Sep 17 00:00:00 2001 From: Luiza Pagliari Date: Tue, 4 Apr 2017 11:09:24 -0300 Subject: [PATCH 1/4] [feature] Create option to automatically reconnect after a few seconds On some erros that display a modal with "Force reconnect" button, allow Etherpad to automatically reload pad after a few seconds. Amount of seconds is defined on settings.json. Still need to create tests for this feature, and implement i18n. --- settings.json.template | 4 + src/node/handler/PadMessageHandler.js | 9 +- src/node/utils/Settings.js | 5 + src/static/css/pad.css | 17 +++ src/static/js/pad_automatic_reconnect.js | 135 +++++++++++++++++++++++ src/static/js/pad_modals.js | 8 +- src/templates/pad.html | 16 +-- 7 files changed, 180 insertions(+), 14 deletions(-) create mode 100644 src/static/js/pad_automatic_reconnect.js diff --git a/settings.json.template b/settings.json.template index 6af5f78a..026f5a2b 100644 --- a/settings.json.template +++ b/settings.json.template @@ -121,6 +121,10 @@ /* Privacy: disable IP logging */ "disableIPlogging" : false, + /* Time (in seconds) to automatically reconnect pad when a "Force reconnect" + message is shown to user. Set to 0 to disable automatic reconnection */ + "automaticReconnectionTimeout" : 0, + /* Users for basic authentication. is_admin = true gives access to /admin. If you do not uncomment this, /admin will not be available! */ /* diff --git a/src/node/handler/PadMessageHandler.js b/src/node/handler/PadMessageHandler.js index 279a44e1..20b262f4 100644 --- a/src/node/handler/PadMessageHandler.js +++ b/src/node/handler/PadMessageHandler.js @@ -936,7 +936,7 @@ function handleSwitchToPad(client, message) var currentSession = sessioninfos[client.id]; var padId = currentSession.padId; var roomClients = _getRoomClients(padId); - + async.forEach(roomClients, function(client, callback) { var sinfo = sessioninfos[client.id]; if(sinfo && sinfo.author == currentSession.author) { @@ -1115,7 +1115,7 @@ function handleClientReady(client, message) //Check if this author is already on the pad, if yes, kick the other sessions! var roomClients = _getRoomClients(pad.id); - + async.forEach(roomClients, function(client, callback) { var sinfo = sessioninfos[client.id]; if(sinfo && sinfo.author == author) { @@ -1176,6 +1176,7 @@ function handleClientReady(client, message) "accountPrivs": { "maxRevisions": 100 }, + "automaticReconnectionTimeout": settings.automaticReconnectionTimeout, "initialRevisionList": [], "initialOptions": { "guestPolicy": "deny" @@ -1676,13 +1677,13 @@ function composePadChangesets(padId, startNum, endNum, callback) function _getRoomClients(padID) { var roomClients = []; var room = socketio.sockets.adapter.rooms[padID]; - + if (room) { for (var id in room.sockets) { roomClients.push(socketio.sockets.sockets[id]); } } - + return roomClients; } diff --git a/src/node/utils/Settings.js b/src/node/utils/Settings.js index 24bc25c3..a564501d 100644 --- a/src/node/utils/Settings.js +++ b/src/node/utils/Settings.js @@ -177,6 +177,11 @@ exports.loglevel = "INFO"; */ exports.disableIPlogging = false; +/** + * Number of seconds to automatically reconnect pad + */ +exports.automaticReconnectionTimeout = 0; + /** * Disable Load Testing */ diff --git a/src/static/css/pad.css b/src/static/css/pad.css index 5764c5e4..0b881d78 100644 --- a/src/static/css/pad.css +++ b/src/static/css/pad.css @@ -517,6 +517,23 @@ table#otheruserstable { display: block; } +/* styles for the automatic reconnection timer: */ +#connectivity .visible.with_reconnect_timer button, +#connectivity .visible.with_reconnect_timer .reconnecttimer * { + display: inline-block; +} + +#connectivity .with_reconnect_timer .hidden, +#connectivity .with_reconnect_timer #defaulttext.hidden, +#connectivity .with_reconnect_timer button.hidden { + display: none; +} + +#connectivity .with_reconnect_timer #cancelreconnect { + margin-left: 10px; +} +/* end of styles for the automatic reconnection timer */ + #reconnect_form button { font-size: 12pt; padding: 5px; diff --git a/src/static/js/pad_automatic_reconnect.js b/src/static/js/pad_automatic_reconnect.js new file mode 100644 index 00000000..6474838c --- /dev/null +++ b/src/static/js/pad_automatic_reconnect.js @@ -0,0 +1,135 @@ + +exports.showCountDownTimerToReconnectOnModal = function($modal) { + if (clientVars.automaticReconnectionTimeout && $modal.is('.with_reconnect_timer')) { + createCountDownElementsIfNecessary($modal); + + var timer = createTimerForModal($modal); + + $modal.find('#cancelreconnect').one('click', function() { + timer.cancel(); + disableAutomaticReconnection($modal); + }); + + enableAutomaticReconnection($modal); + } +} + +var createCountDownElementsIfNecessary = function($modal) { + var elementsDoNotExist = $modal.find('#cancelreconnect').length === 0; + if (elementsDoNotExist) { + var $defaultMessage = $modal.find('#defaulttext'); + var $reconnectButton = $modal.find('#forcereconnect'); + + // create extra DOM elements, if they don't exist + var $reconnectTimerMessage = $('

\ + This window will automatically reconnect in \ + \ +

'); + var $cancelReconnect = $(''); + + $reconnectTimerMessage.insertAfter($defaultMessage); + $cancelReconnect.insertAfter($reconnectButton); + } +} + +var createTimerForModal = function($modal) { + var timer = new CountDownTimer(clientVars.automaticReconnectionTimeout); + + timer.onTick(function(minutes, seconds) { + updateCountDownTimerMessage($modal, minutes, seconds); + }).onExpire(function() { + reconnect($modal); + }).start(); + + return timer; +} + +var disableAutomaticReconnection = function($modal) { + toggleAutomaticReconnectionOption($modal, true); +} +var enableAutomaticReconnection = function($modal) { + toggleAutomaticReconnectionOption($modal, false); +} +var toggleAutomaticReconnectionOption = function($modal, disableAutomaticReconnect) { + $modal.find('#cancelreconnect, .reconnecttimer').toggleClass('hidden', disableAutomaticReconnect); + $modal.find('#defaulttext').toggleClass('hidden', !disableAutomaticReconnect); +} + +var reconnect = function($modal) { + $modal.find('#forcereconnect').click(); +} + +var updateCountDownTimerMessage = function($modal, minutes, seconds) { + minutes = minutes < 10 ? '0' + minutes : minutes; + seconds = seconds < 10 ? '0' + seconds : seconds; + + $modal.find('.timetoexpire').text(minutes + ':' + seconds); +} + +// Timer based on http://stackoverflow.com/a/20618517. +// duration: how many **seconds** until the timer ends +// granularity (optional): how many **milliseconds** between each 'tick' of timer. Default: 1000ms (1s) +var CountDownTimer = function(duration, granularity) { + this.duration = duration; + this.granularity = granularity || 1000; + this.running = false; + + this.onTickCallbacks = []; + this.onExpireCallbacks = []; +} + +CountDownTimer.prototype.start = function() { + if (this.running) { + return; + } + this.running = true; + var start = Date.now(), + that = this, + diff, obj; + + (function timer() { + diff = that.duration - Math.floor((Date.now() - start) / 1000); + + if (diff > 0) { + that.timeoutId = setTimeout(timer, that.granularity); + + obj = CountDownTimer.parse(diff); + that.onTickCallbacks.forEach(function(callback) { + callback.call(this, obj.minutes, obj.seconds); + }, that); + } else { + that.running = false; + + that.onExpireCallbacks.forEach(function(callback) { + callback.call(this); + }, that); + } + }()); +}; + +CountDownTimer.prototype.onTick = function(callback) { + if (typeof callback === 'function') { + this.onTickCallbacks.push(callback); + } + return this; +}; + +CountDownTimer.prototype.onExpire = function(callback) { + if (typeof callback === 'function') { + this.onExpireCallbacks.push(callback); + } + return this; +}; + +CountDownTimer.prototype.cancel = function() { + this.running = false; + clearTimeout(this.timeoutId); + return this; +}; + +CountDownTimer.parse = function(seconds) { + return { + 'minutes': (seconds / 60) | 0, + 'seconds': (seconds % 60) | 0 + }; +}; diff --git a/src/static/js/pad_modals.js b/src/static/js/pad_modals.js index 67b03662..af048875 100644 --- a/src/static/js/pad_modals.js +++ b/src/static/js/pad_modals.js @@ -1,5 +1,5 @@ /** - * This code is mostly from the old Etherpad. Please help us to comment this code. + * This code is mostly from the old Etherpad. Please help us to comment this code. * This helps other people to understand this code better and helps them to improve it. * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ @@ -19,8 +19,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - + var padeditbar = require('./pad_editbar').padeditbar; +var automaticReconnect = require('./pad_automatic_reconnect'); var padmodals = (function() { @@ -35,6 +36,9 @@ var padmodals = (function() padeditbar.toggleDropDown("none", function() { $("#connectivity .visible").removeClass('visible'); $("#connectivity ."+messageId).addClass('visible'); + + automaticReconnect.showCountDownTimerToReconnectOnModal($('#connectivity .' + messageId)); + padeditbar.toggleDropDown("connectivity"); }); }, diff --git a/src/templates/pad.html b/src/templates/pad.html index 3d89f9d0..7c1f1fb1 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -249,12 +249,12 @@

-

+

-

+

@@ -267,16 +267,16 @@

-
+

-

+

-
+

-

+

@@ -288,11 +288,11 @@

-
+
<% e.begin_block("disconnected"); %>

-

+

<% e.end_block(); %>
From 5e907005611caa9ab2b97246f38ad0a086766b80 Mon Sep 17 00:00:00 2001 From: Luiza Pagliari Date: Tue, 4 Apr 2017 13:07:40 -0300 Subject: [PATCH 2/4] [test] Create tests for automatic reconnection on error --- tests/frontend/specs/automatic_reconnect.js | 71 +++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/frontend/specs/automatic_reconnect.js diff --git a/tests/frontend/specs/automatic_reconnect.js b/tests/frontend/specs/automatic_reconnect.js new file mode 100644 index 00000000..e2d2df36 --- /dev/null +++ b/tests/frontend/specs/automatic_reconnect.js @@ -0,0 +1,71 @@ +describe('Automatic pad reload on Force Reconnect message', function() { + var padId, $originalPadFrame; + + beforeEach(function(done) { + padId = helper.newPad(function() { + // enable userdup error to have timer to force reconnect + var $errorMessageModal = helper.padChrome$('#connectivity .userdup'); + $errorMessageModal.addClass('with_reconnect_timer'); + + // make sure there's a timeout set, otherwise automatic reconnect won't be enabled + helper.padChrome$.window.clientVars.automaticReconnectionTimeout = 2; + + // open same pad on another iframe, to force userdup error + var $otherIframeWithSamePad = $(''); + $originalPadFrame = $('#iframe-container iframe'); + $otherIframeWithSamePad.insertAfter($originalPadFrame); + + // wait for modal to be displayed + helper.waitFor(function() { + return $errorMessageModal.is(':visible'); + }, 50000).done(done); + }); + + this.timeout(60000); + }); + + it('displays a count down timer to automatically reconnect', function(done) { + var $errorMessageModal = helper.padChrome$('#connectivity .userdup'); + var $countDownTimer = $errorMessageModal.find('.reconnecttimer'); + + expect($countDownTimer.is(':visible')).to.be(true); + + done(); + }); + + context('and user clicks on Cancel', function() { + beforeEach(function() { + var $errorMessageModal = helper.padChrome$('#connectivity .userdup'); + $errorMessageModal.find('#cancelreconnect').click(); + }); + + it('does not show Cancel button nor timer anymore', function(done) { + var $errorMessageModal = helper.padChrome$('#connectivity .userdup'); + var $countDownTimer = $errorMessageModal.find('.reconnecttimer'); + var $cancelButton = $errorMessageModal.find('#cancelreconnect'); + + expect($countDownTimer.is(':visible')).to.be(false); + expect($cancelButton.is(':visible')).to.be(false); + + done(); + }); + }); + + context('and user does not click on Cancel until timer expires', function() { + var padWasReloaded = false; + + beforeEach(function() { + $originalPadFrame.one('load', function() { + padWasReloaded = true; + }); + }); + + it('reloads the pad', function(done) { + helper.waitFor(function() { + return padWasReloaded; + }, 5000).done(done); + + this.timeout(5000); + }); + }); +}); From 0eae83f2527e2ef963faee5d35993a66e84e26f4 Mon Sep 17 00:00:00 2001 From: Luiza Pagliari Date: Tue, 4 Apr 2017 18:09:33 -0300 Subject: [PATCH 3/4] [feature] i18n for automatic reconnection messages/buttons --- src/locales/en.json | 2 ++ src/static/js/pad_automatic_reconnect.js | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/locales/en.json b/src/locales/en.json index 3e16c5de..2e96880a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -74,6 +74,8 @@ "pad.modals.connected": "Connected.", "pad.modals.reconnecting": "Reconnecting to your pad..", "pad.modals.forcereconnect": "Force reconnect", + "pad.modals.reconnecttimer": "This pad will be automatically reconnected in ", + "pad.modals.cancel": "Cancel", "pad.modals.userdup": "Opened in another window", "pad.modals.userdup.explanation": "This pad seems to be opened in more than one browser window on this computer.", diff --git a/src/static/js/pad_automatic_reconnect.js b/src/static/js/pad_automatic_reconnect.js index 6474838c..3de5ff62 100644 --- a/src/static/js/pad_automatic_reconnect.js +++ b/src/static/js/pad_automatic_reconnect.js @@ -22,16 +22,23 @@ var createCountDownElementsIfNecessary = function($modal) { // create extra DOM elements, if they don't exist var $reconnectTimerMessage = $('

\ - This window will automatically reconnect in \ + This pad will be automatically reconnected in \ \

'); var $cancelReconnect = $(''); + localize($reconnectTimerMessage); + localize($cancelReconnect); + $reconnectTimerMessage.insertAfter($defaultMessage); $cancelReconnect.insertAfter($reconnectButton); } } +var localize = function($element) { + html10n.translateElement(html10n.translations, $element.get(0)); +}; + var createTimerForModal = function($modal) { var timer = new CountDownTimer(clientVars.automaticReconnectionTimeout); From 384697f65396396c821cbd86d3b3be31d385789c Mon Sep 17 00:00:00 2001 From: Luiza Pagliari Date: Wed, 5 Apr 2017 15:07:37 -0300 Subject: [PATCH 4/4] [feature] Only automatically reconnect if can establish connection to server Avoid trying to reload pad when network is not available. --- src/locales/en.json | 2 +- src/static/js/pad_automatic_reconnect.js | 74 ++++++++++++++++++------ src/static/js/pad_modals.js | 3 +- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 2e96880a..9e0d49b3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -74,7 +74,7 @@ "pad.modals.connected": "Connected.", "pad.modals.reconnecting": "Reconnecting to your pad..", "pad.modals.forcereconnect": "Force reconnect", - "pad.modals.reconnecttimer": "This pad will be automatically reconnected in ", + "pad.modals.reconnecttimer": "Trying to reconnect in ", "pad.modals.cancel": "Cancel", "pad.modals.userdup": "Opened in another window", diff --git a/src/static/js/pad_automatic_reconnect.js b/src/static/js/pad_automatic_reconnect.js index 3de5ff62..b5b99bcd 100644 --- a/src/static/js/pad_automatic_reconnect.js +++ b/src/static/js/pad_automatic_reconnect.js @@ -1,9 +1,9 @@ -exports.showCountDownTimerToReconnectOnModal = function($modal) { +exports.showCountDownTimerToReconnectOnModal = function($modal, pad) { if (clientVars.automaticReconnectionTimeout && $modal.is('.with_reconnect_timer')) { createCountDownElementsIfNecessary($modal); - var timer = createTimerForModal($modal); + var timer = createTimerForModal($modal, pad); $modal.find('#cancelreconnect').one('click', function() { timer.cancel(); @@ -22,7 +22,7 @@ var createCountDownElementsIfNecessary = function($modal) { // create extra DOM elements, if they don't exist var $reconnectTimerMessage = $('

\ - This pad will be automatically reconnected in \ + Trying to reconnect in \ \

'); var $cancelReconnect = $(''); @@ -39,13 +39,20 @@ var localize = function($element) { html10n.translateElement(html10n.translations, $element.get(0)); }; -var createTimerForModal = function($modal) { - var timer = new CountDownTimer(clientVars.automaticReconnectionTimeout); +var createTimerForModal = function($modal, pad) { + var timeUntilReconnection = clientVars.automaticReconnectionTimeout * reconnectionTries.nextTry(); + var timer = new CountDownTimer(timeUntilReconnection); timer.onTick(function(minutes, seconds) { updateCountDownTimerMessage($modal, minutes, seconds); }).onExpire(function() { - reconnect($modal); + var wasANetworkError = $modal.is('.disconnected'); + if (wasANetworkError) { + // cannot simply reconnect, client is having issues to establish connection to server + waitUntilClientCanConnectToServerAndThen(function() { forceReconnection($modal); }, pad); + } else { + forceReconnection($modal); + } }).start(); return timer; @@ -62,7 +69,20 @@ var toggleAutomaticReconnectionOption = function($modal, disableAutomaticReconne $modal.find('#defaulttext').toggleClass('hidden', !disableAutomaticReconnect); } -var reconnect = function($modal) { +var waitUntilClientCanConnectToServerAndThen = function(callback, pad) { + whenConnectionIsRestablishedWithServer(callback, pad); + pad.socket.connect(); +} + +var whenConnectionIsRestablishedWithServer = function(callback, pad) { + // only add listener for the first try, don't need to add another listener + // on every unsuccessful try + if (reconnectionTries.counter === 1) { + pad.socket.once('connect', callback); + } +} + +var forceReconnection = function($modal) { $modal.find('#forcereconnect').click(); } @@ -73,6 +93,20 @@ var updateCountDownTimerMessage = function($modal, minutes, seconds) { $modal.find('.timetoexpire').text(minutes + ':' + seconds); } +// store number of tries to reconnect to server, in order to increase time to wait +// until next try +var reconnectionTries = { + counter: 0, + + nextTry: function() { + // double the time to try to reconnect on every time reconnection fails + var nextCounterFactor = Math.pow(2, this.counter); + this.counter++; + + return nextCounterFactor; + } +} + // Timer based on http://stackoverflow.com/a/20618517. // duration: how many **seconds** until the timer ends // granularity (optional): how many **milliseconds** between each 'tick' of timer. Default: 1000ms (1s) @@ -92,28 +126,34 @@ CountDownTimer.prototype.start = function() { this.running = true; var start = Date.now(), that = this, - diff, obj; + diff; (function timer() { diff = that.duration - Math.floor((Date.now() - start) / 1000); if (diff > 0) { that.timeoutId = setTimeout(timer, that.granularity); - - obj = CountDownTimer.parse(diff); - that.onTickCallbacks.forEach(function(callback) { - callback.call(this, obj.minutes, obj.seconds); - }, that); + that.tick(diff); } else { that.running = false; - - that.onExpireCallbacks.forEach(function(callback) { - callback.call(this); - }, that); + that.tick(0); + that.expire(); } }()); }; +CountDownTimer.prototype.tick = function(diff) { + var obj = CountDownTimer.parse(diff); + this.onTickCallbacks.forEach(function(callback) { + callback.call(this, obj.minutes, obj.seconds); + }, this); +} +CountDownTimer.prototype.expire = function() { + this.onExpireCallbacks.forEach(function(callback) { + callback.call(this); + }, this); +} + CountDownTimer.prototype.onTick = function(callback) { if (typeof callback === 'function') { this.onTickCallbacks.push(callback); diff --git a/src/static/js/pad_modals.js b/src/static/js/pad_modals.js index af048875..2fc621dc 100644 --- a/src/static/js/pad_modals.js +++ b/src/static/js/pad_modals.js @@ -37,7 +37,8 @@ var padmodals = (function() $("#connectivity .visible").removeClass('visible'); $("#connectivity ."+messageId).addClass('visible'); - automaticReconnect.showCountDownTimerToReconnectOnModal($('#connectivity .' + messageId)); + var $modal = $('#connectivity .' + messageId); + automaticReconnect.showCountDownTimerToReconnectOnModal($modal, pad); padeditbar.toggleDropDown("connectivity"); });