diff --git a/www/socket/index.html b/www/socket/index.html
new file mode 100644
index 000000000..6ee2a8596
--- /dev/null
+++ b/www/socket/index.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/www/socket/inner.html b/www/socket/inner.html
new file mode 100644
index 000000000..bf79dcd0d
--- /dev/null
+++ b/www/socket/inner.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/www/socket/main.js b/www/socket/main.js
new file mode 100644
index 000000000..cbb7f66ed
--- /dev/null
+++ b/www/socket/main.js
@@ -0,0 +1,288 @@
+define([
+ '/api/config?cb=' + Math.random().toString(16).substring(2),
+ '/common/messages.js',
+ '/common/crypto.js',
+ '/socket/realtime-input.js',
+ '/common/convert.js',
+ '/socket/toolbar.js',
+ '/common/cursor.js',
+ '/common/json-ot.js',
+ '/bower_components/diff-dom/diffDOM.js',
+ '/bower_components/jquery/dist/jquery.min.js',
+ '/customize/pad.js'
+], function (Config, Messages, Crypto, realtimeInput, Convert, Toolbar, Cursor, JsonOT) {
+ var $ = window.jQuery;
+ var ifrw = $('#pad-iframe')[0].contentWindow;
+ var Ckeditor; // to be initialized later...
+ var DiffDom = window.diffDOM;
+
+ window.Convert = Convert;
+
+ window.Toolbar = Toolbar;
+
+ var userName = Crypto.rand64(8),
+ toolbar;
+
+ var module = {};
+
+ var andThen = function (Ckeditor) {
+ $(window).on('hashchange', function() {
+ window.location.reload();
+ });
+ if (window.location.href.indexOf('#') === -1) {
+ window.location.href = window.location.href + '#' + Crypto.genKey();
+ return;
+ }
+
+ var fixThings = false;
+ var key = Crypto.parseKey(window.location.hash.substring(1));
+ var editor = window.editor = Ckeditor.replace('editor1', {
+ // https://dev.ckeditor.com/ticket/10907
+ needsBrFiller: fixThings,
+ needsNbspFiller: fixThings,
+ removeButtons: 'Source,Maximize',
+ // magicline plugin inserts html crap into the document which is not part of the
+ // document itself and causes problems when it's sent across the wire and reflected back
+ // but we filter it now, so that's ok.
+ removePlugins: 'resize'
+ });
+
+ editor.on('instanceReady', function (Ckeditor) {
+ editor.execCommand('maximize');
+ var documentBody = ifrw.$('iframe')[0].contentDocument.body;
+
+ documentBody.innerHTML = Messages.initialState;
+
+ var inner = window.inner = documentBody;
+ var cursor = window.cursor = Cursor(inner);
+
+ var $textarea = $('#feedback');
+
+ var setEditable = function (bool) {
+ // inner.style.backgroundColor = bool? 'unset': 'grey';
+ inner.setAttribute('contenteditable', bool);
+ };
+
+ // don't let the user edit until the pad is ready
+ setEditable(false);
+
+ var diffOptions = {
+ preDiffApply: function (info) {
+
+ // no use trying to recover the cursor if it doesn't exist
+ if (!cursor.exists()) { return; }
+
+ /* frame is either 0, 1, 2, or 3, depending on which
+ cursor frames were affected: none, first, last, or both
+ */
+ var frame = info.frame = cursor.inNode(info.node);
+
+ if (!frame) { return; }
+
+ if (typeof info.diff.oldValue === 'string' && typeof info.diff.newValue === 'string') {
+ var pushes = cursor.pushDelta(info.diff.oldValue, info.diff.newValue);
+
+ if (frame & 1) {
+ // push cursor start if necessary
+ if (pushes.commonStart < cursor.Range.start.offset) {
+ cursor.Range.start.offset += pushes.delta;
+ }
+ }
+ if (frame & 2) {
+ // push cursor end if necessary
+ if (pushes.commonStart < cursor.Range.end.offset) {
+ cursor.Range.end.offset += pushes.delta;
+ }
+ }
+ }
+ },
+ postDiffApply: function (info) {
+ if (info.frame) {
+ if (info.node) {
+ if (info.frame & 1) { cursor.fixStart(info.node); }
+ if (info.frame & 2) { cursor.fixEnd(info.node); }
+ } else { console.error("info.node did not exist"); }
+
+ var sel = cursor.makeSelection();
+ var range = cursor.makeRange();
+
+ cursor.fixSelection(sel, range);
+ }
+ }
+ };
+
+ var initializing = true;
+
+ var assertStateMatches = function () {
+ var userDocState = module.realtimeInput.realtime.getUserDoc();
+ var currentState = $textarea.val();
+ if (currentState !== userDocState) {
+ console.log({
+ userDocState: userDocState,
+ currentState: currentState
+ });
+ throw new Error("currentState !== userDocState");
+ }
+ };
+
+ // apply patches, and try not to lose the cursor in the process!
+ var applyHjson = function (shjson) {
+ setEditable(false);
+ var userDocStateDom = Convert.hjson.to.dom(JSON.parse(shjson));
+ userDocStateDom.setAttribute("contenteditable", "true"); // lol wtf
+ var DD = new DiffDom(diffOptions);
+
+ assertStateMatches();
+
+ var patch = (DD).diff(inner, userDocStateDom);
+ (DD).apply(inner, patch);
+
+ // push back to the textarea so we get a userDocState
+ setEditable(true);
+ };
+
+ var onRemote = function (shjson) {
+ if (initializing) { return; }
+
+ // remember where the cursor is
+ cursor.update();
+
+ // TODO call propogate
+
+ // build a dom from HJSON, diff, and patch the editor
+ applyHjson(shjson);
+ };
+
+ var onInit = function (info) {
+ var $bar = $('#pad-iframe')[0].contentWindow.$('#cke_1_toolbox');
+ toolbar = info.realtime.toolbar = Toolbar.create($bar, userName, info.realtime);
+ /* TODO handle disconnects and such*/
+ };
+
+ var onReady = function (info) {
+ console.log("Unlocking editor");
+ initializing = false;
+ setEditable(true);
+ applyHjson($textarea.val());
+ $textarea.trigger('keyup');
+ };
+
+ var onAbort = function (info) {
+ console.log("Aborting the session!");
+ // stop the user from continuing to edit
+ setEditable(false);
+ // TODO inform them that the session was torn down
+ toolbar.failed();
+ };
+
+ var realtimeOptions = {
+ // configuration :D
+ doc: inner,
+ // first thing called
+ onInit: onInit,
+
+ onReady: onReady,
+
+ // when remote changes occur
+ onRemote: onRemote,
+
+ // handle aborts
+ onAbort: onAbort,
+
+ // really basic operational transform
+ transformFunction : JsonOT.validate
+ // pass in websocket/netflux object TODO
+ };
+
+ var rti = module.realtimeInput = window.rti = realtimeInput.start($textarea[0], // synced element
+ Config.websocketURL, // websocketURL, ofc
+ userName, // userName
+ key.channel, // channelName
+ key.cryptKey, // key
+ realtimeOptions);
+
+ $textarea.val(JSON.stringify(Convert.dom.to.hjson(inner)));
+
+ var isNotMagicLine = function (el) {
+ // factor as:
+ // return !(el.tagName === 'SPAN' && el.contentEditable === 'false');
+ var filter = (el.tagName === 'SPAN' && el.contentEditable === 'false');
+ if (filter) {
+ console.log("[hyperjson.serializer] prevented an element" +
+ "from being serialized:", el);
+ return false;
+ }
+ return true;
+ };
+
+ var propogate = function () {
+ var hjson = Convert.core.hyperjson.fromDOM(inner, isNotMagicLine);
+
+ $textarea.val(JSON.stringify(hjson));
+ rti.bumpSharejs();
+ };
+
+ var testInput = window.testInput = function (el, offset) {
+ var i = 0,
+ j = offset,
+ input = "The quick red fox jumped over the lazy brown dog. ",
+ l = input.length,
+ errors = 0,
+ max_errors = 15,
+ interval;
+ var cancel = function () {
+ if (interval) { window.clearInterval(interval); }
+ };
+
+ interval = window.setInterval(function () {
+ propogate();
+ try {
+ el.replaceData(j, 0, input.charAt(i));
+ } catch (err) {
+ errors++;
+ if (errors >= max_errors) {
+ console.log("Max error number exceeded");
+ cancel();
+ }
+
+ console.error(err);
+ var next = document.createTextNode("");
+ el.parentNode.appendChild(next);
+ el = next;
+ j = 0;
+ }
+ i = (i + 1) % l;
+ j++;
+ }, 200);
+
+ return {
+ cancel: cancel
+ };
+ };
+
+ var easyTest = window.easyTest = function () {
+ cursor.update();
+ var start = cursor.Range.start;
+ var test = testInput(start.el, start.offset);
+ //window.rti.bumpSharejs();
+ propogate();
+ return test;
+ };
+
+ editor.on('change', propogate);
+ });
+ };
+
+ var interval = 100;
+ var first = function () {
+ Ckeditor = ifrw.CKEDITOR;
+ if (Ckeditor) {
+ andThen(Ckeditor);
+ } else {
+ console.log("Ckeditor was not defined. Trying again in %sms",interval);
+ setTimeout(first, interval);
+ }
+ };
+
+ $(first);
+});
diff --git a/www/socket/realtime-input.js b/www/socket/realtime-input.js
new file mode 100644
index 000000000..556a4b3c8
--- /dev/null
+++ b/www/socket/realtime-input.js
@@ -0,0 +1,329 @@
+/*
+ * Copyright 2014 XWiki SAS
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+define([
+ '/common/messages.js',
+ '/bower_components/reconnectingWebsocket/reconnecting-websocket.js',
+ '/common/crypto.js',
+ '/common/sharejs_textarea.js',
+ '/common/chainpad.js',
+ '/bower_components/jquery/dist/jquery.min.js',
+], function (Messages, ReconnectingWebSocket, Crypto, sharejs) {
+ var $ = window.jQuery;
+ var ChainPad = window.ChainPad;
+ var PARANOIA = true;
+ var module = { exports: {} };
+
+ /**
+ * If an error is encountered but it is recoverable, do not immediately fail
+ * but if it keeps firing errors over and over, do fail.
+ */
+ var MAX_RECOVERABLE_ERRORS = 15;
+
+ /** Maximum number of milliseconds of lag before we fail the connection. */
+ var MAX_LAG_BEFORE_DISCONNECT = 20000;
+
+ var debug = function (x) { console.log(x); },
+ warn = function (x) { console.error(x); },
+ verbose = function (x) { /*console.log(x);*/ };
+
+ // ------------------ Trapping Keyboard Events ---------------------- //
+
+ var bindEvents = function (element, events, callback, unbind) {
+ for (var i = 0; i < events.length; i++) {
+ var e = events[i];
+ if (element.addEventListener) {
+ if (unbind) {
+ element.removeEventListener(e, callback, false);
+ } else {
+ element.addEventListener(e, callback, false);
+ }
+ } else {
+ if (unbind) {
+ element.detachEvent('on' + e, callback);
+ } else {
+ element.attachEvent('on' + e, callback);
+ }
+ }
+ }
+ };
+
+ var bindAllEvents = function (textarea, docBody, onEvent, unbind)
+ {
+ /*
+ we use docBody for the purposes of CKEditor.
+ because otherwise special keybindings like ctrl-b and ctrl-i
+ would open bookmarks and info instead of applying bold/italic styles
+ */
+ if (docBody) {
+ bindEvents(docBody,
+ ['textInput', 'keydown', 'keyup', 'select', 'cut', 'paste'],
+ onEvent,
+ unbind);
+ }
+ bindEvents(textarea,
+ ['mousedown','mouseup','click','change'],
+ onEvent,
+ unbind);
+ };
+
+ /* websocket stuff */
+ var isSocketDisconnected = function (socket, realtime) {
+ var sock = socket._socket;
+ return sock.readyState === sock.CLOSING
+ || sock.readyState === sock.CLOSED
+ || (realtime.getLag().waiting && realtime.getLag().lag > MAX_LAG_BEFORE_DISCONNECT);
+ };
+
+ // this differs from other functions with similar names in that
+ // you are expected to pass a socket into it.
+ var checkSocket = function (socket) {
+ if (isSocketDisconnected(socket, socket.realtime) &&
+ !socket.intentionallyClosing) {
+ return true;
+ } else {
+ return false;
+ }
+ };
+
+ // TODO before removing websocket implementation
+ // bind abort to onLeaving
+ var abort = function (socket, realtime) {
+ realtime.abort();
+ try { socket._socket.close(); } catch (e) { warn(e); }
+ };
+
+ var handleError = function (socket, realtime, err, docHTML, allMessages) {
+ // var internalError = createDebugInfo(err, realtime, docHTML, allMessages);
+ abort(socket, realtime);
+ };
+
+ var makeWebsocket = function (url) {
+ var socket = new ReconnectingWebSocket(url);
+ var out = {
+ onOpen: [],
+ onClose: [],
+ onError: [],
+ onMessage: [],
+ send: function (msg) { socket.send(msg); },
+ close: function () { socket.close(); },
+ _socket: socket
+ };
+ var mkHandler = function (name) {
+ return function (evt) {
+ for (var i = 0; i < out[name].length; i++) {
+ if (out[name][i](evt) === false) {
+ console.log(name +"Handler");
+ return;
+ }
+ }
+ };
+ };
+ socket.onopen = mkHandler('onOpen');
+ socket.onclose = mkHandler('onClose');
+ socket.onerror = mkHandler('onError');
+ socket.onmessage = mkHandler('onMessage');
+ return out;
+ };
+ /* end websocket stuff */
+
+ var start = module.exports.start =
+ function (textarea, websocketUrl, userName, channel, cryptKey, config)
+ {
+
+ var passwd = 'y';
+
+ // make sure configuration is defined
+ config = config || {};
+
+ var doc = config.doc || null;
+
+ // trying to deprecate onRemote, prefer loading it via the conf
+ var onRemote = config.onRemote || null;
+
+ var transformFunction = config.transformFunction || null;
+
+ var socket;
+
+ if (config.socketAdaptor) {
+ // do netflux stuff
+ } else {
+ socket = makeWebsocket(websocketUrl);
+ }
+ // define this in case it gets called before the rest of our stuff is ready.
+ var onEvent = function () { };
+
+ var allMessages = [];
+ var isErrorState = false;
+ var initializing = true;
+ var recoverableErrorCount = 0;
+
+ var $textarea = $(textarea);
+
+ var bump = function () {};
+
+ var toReturn = {
+ socket: socket
+ };
+
+ socket.onOpen.push(function (evt) {
+ if (!initializing) {
+ console.log("Starting");
+ // realtime is passed around as an attribute of the socket
+ // FIXME??
+ socket.realtime.start();
+ return;
+ }
+
+ var realtime = toReturn.realtime = socket.realtime = ChainPad.create(userName,
+ passwd,
+ channel,
+ $(textarea).val(),
+ {
+ transformFunction: config.transformFunction
+ });
+
+ if (config.onInit) {
+ // extend as you wish
+ config.onInit({
+ realtime: realtime
+ });
+ }
+
+ onEvent = function () {
+ // This looks broken
+ if (isErrorState || initializing) { return; }
+ };
+
+ realtime.onUserListChange(function (userList) {
+ if (!initializing || userList.indexOf(userName) === -1) {
+ return;
+ }
+ // if we spot ourselves being added to the document, we'll switch
+ // 'initializing' off because it means we're fully synced.
+ initializing = false;
+
+ // execute an onReady callback if one was supplied
+ // pass an object so we can extend this later
+ if (config.onReady) {
+ // extend as you wish
+ config.onReady({
+ userList: userList
+ });
+ }
+ });
+
+ var whoami = new RegExp(userName.replace(/[\/\+]/g, function (c) {
+ return '\\' +c;
+ }));
+
+ // when you receive a message...
+ socket.onMessage.push(function (evt) {
+ verbose(evt.data);
+ if (isErrorState) { return; }
+
+ var message = Crypto.decrypt(evt.data, cryptKey);
+ verbose(message);
+ allMessages.push(message);
+ if (!initializing) {
+ if (PARANOIA) {
+ // FIXME this is out of sync with the application logic
+ onEvent();
+ }
+ }
+ realtime.message(message);
+ if (/\[5,/.test(message)) { verbose("pong"); }
+
+ if (!initializing) {
+ if (/\[2,/.test(message)) {
+ //verbose("Got a patch");
+ if (whoami.test(message)) {
+ //verbose("Received own message");
+ } else {
+ //verbose("Received remote message");
+ // obviously this is only going to get called if
+ if (onRemote) { onRemote(realtime.getUserDoc()); }
+ }
+ }
+ }
+ });
+
+ // when a message is ready to send
+ realtime.onMessage(function (message) {
+ if (isErrorState) { return; }
+ message = Crypto.encrypt(message, cryptKey);
+ try {
+ socket.send(message);
+ } catch (e) {
+ warn(e);
+ }
+ });
+
+ // actual socket bindings
+ socket.onmessage = function (evt) {
+ for (var i = 0; i < socket.onMessage.length; i++) {
+ if (socket.onMessage[i](evt) === false) { return; }
+ }
+ };
+ socket.onclose = function (evt) {
+ for (var i = 0; i < socket.onMessage.length; i++) {
+ if (socket.onClose[i](evt) === false) { return; }
+ }
+ };
+
+ socket.onerror = warn;
+
+ // TODO confirm that we can rely on netflux API
+ var socketChecker = setInterval(function () {
+ if (checkSocket(socket)) {
+ warn("Socket disconnected!");
+
+ recoverableErrorCount += 1;
+
+ if (recoverableErrorCount >= MAX_RECOVERABLE_ERRORS) {
+ warn("Giving up!");
+ abort(socket, realtime);
+ if (config.onAbort) {
+ config.onAbort({
+ socket: socket
+ });
+ }
+ if (socketChecker) { clearInterval(socketChecker); }
+ }
+ } else {
+ // it's working as expected, continue
+ }
+ }, 200);
+
+ bindAllEvents(textarea, doc, onEvent, false);
+
+ // attach textarea
+ // NOTE: should be able to remove the websocket without damaging this
+ sharejs.attach(textarea, realtime);
+
+ realtime.start();
+ debug('started');
+
+ bump = realtime.bumpSharejs;
+ });
+
+ toReturn.onEvent = function () { onEvent(); };
+ toReturn.bumpSharejs = function () { bump(); };
+
+ return toReturn;
+ };
+ return module.exports;
+});
diff --git a/www/socket/toolbar.js b/www/socket/toolbar.js
new file mode 100644
index 000000000..4159ea0ac
--- /dev/null
+++ b/www/socket/toolbar.js
@@ -0,0 +1,233 @@
+define([
+ '/common/messages.js'
+], function (Messages) {
+
+ /** Id of the element for getting debug info. */
+ var DEBUG_LINK_CLS = 'rtwysiwyg-debug-link';
+
+ /** Id of the div containing the user list. */
+ var USER_LIST_CLS = 'rtwysiwyg-user-list';
+
+ /** Id of the div containing the lag info. */
+ var LAG_ELEM_CLS = 'rtwysiwyg-lag';
+
+ /** The toolbar class which contains the user list, debug link and lag. */
+ var TOOLBAR_CLS = 'rtwysiwyg-toolbar';
+
+ /** Key in the localStore which indicates realtime activity should be disallowed. */
+ var LOCALSTORAGE_DISALLOW = 'rtwysiwyg-disallow';
+
+ var SPINNER_DISAPPEAR_TIME = 3000;
+ var SPINNER = [ '-', '\\', '|', '/' ];
+
+ var uid = function () {
+ return 'rtwysiwyg-uid-' + String(Math.random()).substring(2);
+ };
+
+ var createRealtimeToolbar = function ($container) {
+ var id = uid();
+ $container.prepend(
+ ''
+ );
+ var toolbar = $container.find('#'+id);
+ toolbar.append([
+ ''
+ ].join('\n'));
+ return toolbar;
+ };
+
+ var createEscape = function ($container) {
+ var id = uid();
+ $container.append('⇐ Back
');
+ var $ret = $container.find('#'+id);
+ $ret.on('click', function () {
+ window.location.href = '/';
+ });
+ return $ret[0];
+ };
+
+ var createSpinner = function ($container) {
+ var id = uid();
+ $container.append('');
+ return $container.find('#'+id)[0];
+ };
+
+ var kickSpinner = function (spinnerElement, reversed) {
+ var txt = spinnerElement.textContent || '-';
+ var inc = (reversed) ? -1 : 1;
+ spinnerElement.textContent = SPINNER[(SPINNER.indexOf(txt) + inc) % SPINNER.length];
+ if (spinnerElement.timeout) { clearTimeout(spinnerElement.timeout); }
+ spinnerElement.timeout = setTimeout(function () {
+ spinnerElement.textContent = '';
+ }, SPINNER_DISAPPEAR_TIME);
+ };
+
+ var createUserList = function ($container) {
+ var id = uid();
+ $container.append('');
+ return $container.find('#'+id)[0];
+ };
+
+ var updateUserList = function (myUserName, listElement, userList) {
+ var meIdx = userList.indexOf(myUserName);
+ if (meIdx === -1) {
+ listElement.textContent = Messages.synchronizing;
+ return;
+ }
+ if (userList.length === 1) {
+ listElement.textContent = Messages.editingAlone;
+ } else if (userList.length === 2) {
+ listElement.textContent = Messages.editingWithOneOtherPerson;
+ } else {
+ listElement.textContent = Messages.editingWith + ' ' + (userList.length - 1) + ' ' + Messages.otherPeople;
+ }
+ };
+
+ var createLagElement = function ($container) {
+ var id = uid();
+ $container.append('');
+ return $container.find('#'+id)[0];
+ };
+
+ var checkLag = function (realtime, lagElement) {
+ var lag = realtime.getLag();
+ var lagSec = lag.lag/1000;
+ var lagMsg = Messages.lag + ' ';
+ if (lag.waiting && lagSec > 1) {
+ lagMsg += "?? " + Math.floor(lagSec);
+ } else {
+ lagMsg += lagSec;
+ }
+ lagElement.textContent = lagMsg;
+ };
+
+ // this is a little hack, it should go in it's own file.
+ // FIXME ok, so let's put it in its own file then
+ // TODO there should also be a 'clear recent pads' button
+ var rememberPad = function () {
+ // FIXME, this is overly complicated, use array methods
+ var recentPadsStr = localStorage['CryptPad_RECENTPADS'];
+ var recentPads = [];
+ if (recentPadsStr) { recentPads = JSON.parse(recentPadsStr); }
+ // TODO use window.location.hash or something like that
+ if (window.location.href.indexOf('#') === -1) { return; }
+ var now = new Date();
+ var out = [];
+ for (var i = recentPads.length; i >= 0; i--) {
+ if (recentPads[i] &&
+ // TODO precompute this time value, maybe make it configurable?
+ // FIXME precompute the date too, why getTime every time?
+ now.getTime() - recentPads[i][1] < (1000*60*60*24*30) &&
+ recentPads[i][0] !== window.location.href)
+ {
+ out.push(recentPads[i]);
+ }
+ }
+ out.push([window.location.href, now.getTime()]);
+ localStorage['CryptPad_RECENTPADS'] = JSON.stringify(out);
+ };
+
+ var create = function ($container, myUserName, realtime) {
+ var toolbar = createRealtimeToolbar($container);
+ createEscape(toolbar.find('.rtwysiwyg-toolbar-leftside'));
+ var userListElement = createUserList(toolbar.find('.rtwysiwyg-toolbar-leftside'));
+ var spinner = createSpinner(toolbar.find('.rtwysiwyg-toolbar-rightside'));
+ var lagElement = createLagElement(toolbar.find('.rtwysiwyg-toolbar-rightside'));
+
+ rememberPad();
+
+ var connected = false;
+
+ realtime.onUserListChange(function (userList) {
+ if (userList.indexOf(myUserName) !== -1) { connected = true; }
+ if (!connected) { return; }
+ updateUserList(myUserName, userListElement, userList);
+ });
+
+ var ks = function () {
+ if (connected) { kickSpinner(spinner, false); }
+ };
+
+ realtime.onPatch(ks);
+ // Try to filter out non-patch messages, doesn't have to be perfect this is just the spinner
+ realtime.onMessage(function (msg) { if (msg.indexOf(':[2,') > -1) { ks(); } });
+
+ setInterval(function () {
+ if (!connected) { return; }
+ checkLag(realtime, lagElement);
+ }, 3000);
+
+ return {
+ failed: function () {
+ connected = false;
+ userListElement.textContent = '';
+ lagElement.textContent = '';
+ },
+ reconnecting: function () {
+ connected = false;
+ userListElement.textContent = Messages.reconnecting;
+ lagElement.textContent = '';
+ },
+ connected: function () {
+ connected = true;
+ }
+ };
+ };
+
+ return { create: create };
+});