c9-core/plugins/c9.ide.collab/collab.js

929 wiersze
35 KiB
JavaScript
Czysty Wina Historia

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

define(function(require, exports, module) {
"use strict";
main.consumes = [
"Panel", "tabManager", "fs", "metadata", "ui", "apf", "settings",
"preferences", "ace", "util", "collab.connect", "collab.workspace",
"timeslider", "OTDocument", "notification.bubble", "dialog.error", "dialog.alert",
"collab.util", "error_handler", "layout", "menus", "installer", "c9"
];
main.provides = ["collab"];
return main;
function main(options, imports, register) {
var Panel = imports.Panel;
var tabManager = imports.tabManager;
var fs = imports.fs;
var c9 = imports.c9;
var metadata = imports.metadata;
var installer = imports.installer;
var ui = imports.ui;
var apf = imports.apf;
var ace = imports.ace;
var util = imports.util;
var collabUtil = imports["collab.util"];
var settings = imports.settings;
var prefs = imports.preferences;
var connect = imports["collab.connect"];
var workspace = imports["collab.workspace"];
var bubble = imports["notification.bubble"];
var timeslider = imports.timeslider;
var OTDocument = imports.OTDocument;
var showAlert = imports["dialog.alert"].show;
var showError = imports["dialog.error"].show;
var errorHandler = imports.error_handler;
var layout = imports.layout;
var menus = imports.menus;
var css = require("text!./collab.css");
var plugin = new Panel("Ajax.org", main.consumes, {
index: 45,
width: 250,
caption: "Collaborate",
buttonCSSClass: "collab",
panelCSSClass: "collab-bar",
minWidth: 130,
where: "right"
});
var emit = plugin.getEmitter();
// open collab documents
var documents = Object.create(null);
var openFallbackTimeouts = Object.create(null);
var saveFallbackTimeouts = Object.create(null);
var usersLeaving = Object.create(null);
var failedSaveAttempts = 0;
var OPEN_FILESYSTEM_FALLBACK_TIMEOUT = 6000;
var SAVE_FILESYSTEM_FALLBACK_TIMEOUT = 30000;
var SAVE_FILESYSTEM_FALLBACK_TIMEOUT_REPEATED = 15000;
// Check that all the dependencies are installed
installer.createSession("c9.ide.collab", require("./install"));
var loaded = false;
function load() {
if (loaded) return;
loaded = true;
connect.on("message", onMessage);
connect.on("connecting", onConnecting);
connect.on("connect", onConnectMsg);
connect.on("disconnect", onDisconnect);
metadata.on("beforeReadFile", beforeReadFile, plugin);
fs.on("afterReadFile", afterReadFile, plugin);
fs.on("beforeWriteFile", beforeWriteFile, plugin);
ace.on("initAceSession", function(e) {
var doc = e.doc;
var path = doc.tab.path;
var otDoc = documents[path];
if (otDoc && !otDoc.session)
otDoc.setSession(doc.getSession().session);
});
tabManager.on("focusSync", function(e) {
var tab = e.tab;
var otDoc = documents[tab.path];
if (otDoc && !otDoc.session) {
var doc = tab.document;
var docSession = doc.getSession();
docSession && otDoc.setSession(docSession.session);
}
});
tabManager.on("focus", function(e) {
var tab = e.tab;
if (tab && tab.editor) {
var id = getTabId(tab);
id && connect.send("MESSAGE", {
action: "focus",
clientId: workspace.myClientId,
userId: workspace.myUserId,
tabId: id
});
}
});
plugin.on("userMessage", function(e) {
if (e.action == "focus") {
workspace.updateOpenDocs(e, "activate");
}
});
ui.insertCss(css, null, plugin);
window.addEventListener("unload", function() {
leaveAll();
}, false);
tabManager.on("open", function(e) {
var tab = e.tab;
tab.on("setPath", function(e) {
onSetPath(tab, e.oldpath, e.path);
});
});
tabManager.on("tabDestroy", function(e) {
leaveDocument(e.tab.path);
}, plugin);
// Author layer settings
var showAuthorInfoKey = "user/collab/@show-author-info";
prefs.add({
"General": {
"Collaboration": {
"Show Authorship Info": {
type: "checkbox",
position: 8000,
path: showAuthorInfoKey
}
}
}
}, plugin);
settings.on("read", function () {
settings.setDefaults("user/collab", [["show-author-info", true]]);
refreshActiveDocuments();
}, plugin);
settings.on("user/collab", function () {
refreshActiveDocuments();
}, plugin);
workspace.on("sync", scheduleUpdateUserBadges);
scheduleUpdateUserBadges();
}
var drawn = false;
function draw(options) {
if (drawn) return;
drawn = true;
var bar = options.aml;
var html = bar.$int;
emit.sticky("drawPanels", { html: html, aml: bar });
}
function onDisconnect() {
for (var docId in documents) {
var doc = documents[docId];
doc.disconnect();
}
// bubbleNotification("Collab disconnected");
emit("disconnect");
}
function onConnecting () {
// bubbleNotification("Collab connecting");
}
function onConnectMsg(msg) {
workspace.syncWorkspace(msg.data);
for (var docId in documents)
documents[docId].load();
// bubbleNotification(msg.err || "Collab connected");
emit("connect");
}
function onMessage(msg) {
var data = msg.data || {};
var user = data && data.userId && workspace.getUser(data.userId);
var type = msg.type;
var docId = data.docId;
if (docId && "/~".indexOf(docId[0]) === -1)
docId = data.docId = "/" + docId;
var doc = documents[docId];
if (!connect.connected && type !== "CONNECT")
return console.warn("[OT] Not connected - ignoring:", msg);
if (data.clientId && data.clientId === workspace.myOldClientId)
return console.warn("[OT] Skipping my own 'away' disconnection notifications");
switch (type) {
case "CHAT_MESSAGE":
data.increment = true;
emit("chatMessage", data);
break;
case "USER_JOIN":
user = data.user;
if (!user)
break;
workspace.joinClient(user, data.clientId);
notifyUserOnline(user);
break;
case "USER_LEAVE":
workspace.leaveClient(data.userId, data.clientId);
notifyUserOffline(user);
break;
case "LEAVE_DOC":
workspace.updateOpenDocs(data, "leave");
doc && doc.clientLeave(data.clientId);
break;
case "JOIN_DOC":
workspace.updateOpenDocs(data, "join");
if (workspace.myClientId !== data.clientId)
return;
if (!doc)
return console.warn("[OT] Received msg for file that is not open - docId:", docId, "open docs:", Object.keys(documents));
doc.joinData(data);
break;
case "RESOLVE_CONFLICT":
emit("resolveConflict", { path: docId });
break;
case "LARGE_DOC":
doc && doc.leave();
doc && reportLargeDocument(doc, !msg.data.response);
delete documents[docId];
break;
case "DOC_CHANGED_ON_DISK":
reportDocChangedOnDisk(docId);
break;
case "DOC_HAS_PENDING_CHANGES":
reportDocHasPendingChanges(docId);
break;
case "USER_STATE":
workspace.updateUserState(data.userId, data.state);
break;
case "CLEAR_CHAT":
emit("chatClear", data);
break;
case "MESSAGE":
if (emit("userMessage", data) !== false)
handleUserMessage(data);
break;
case "ERROR":
errorHandler.log(
data.err || new Error("Collab error"),
util.extend({}, { users: workspace.users, userId: workspace.myUserId, clientId: workspace.myClientId }, data)
);
break;
case "POST_PROCESSOR_ERROR":
emit("postProcessorError", data);
return;
case "RESET_DB":
showAlert("Uh oh!",
"Workspace issue encountered",
"Your workspace encountered an issue, but dont worry, weve resolved it. " +
"Your data is still intact, however your file revision history may have been lost. " +
"Give us just a moment to complete the recovery so you can get back to your project. ",
function() {
setTimeout(function() {
window.location.reload();
}, 1000);
}
);
break;
default:
if (!doc)
return console.warn("[OT] Received msg for file that is not open", docId, msg);
if (doc.loaded)
doc.handleMessage(msg);
else
console.warn("[OT] Doc ", docId, " not yet inited - MSG:", msg);
}
}
function notifyUserOffline(user) {
clearTimeout(usersLeaving[user.fullname]);
usersLeaving[user.fullname] = setTimeout(function() {
if (!user.online)
bubbleNotification("went offline", user);
delete usersLeaving[user.fullname];
}, 4000);
}
function notifyUserOnline(user) {
if (usersLeaving[user.fullname]) {
// User left for like 4 seconds, don't notify
clearTimeout(usersLeaving[user.fullname]);
return;
}
if (user.online <= 1)
bubbleNotification("came online", user);
}
/**
* Join a document and report progress and on-load contents
* @param {String} docId
* @param {Document} doc
* @param {Function} [progress]
*/
function joinDocument(docId, doc, progress) {
console.log("[OT] Join", docId);
var docSession = doc.getSession();
var aceSession = docSession && docSession.session;
if (!aceSession)
console.warn("[OT] Ace session not ready for:", docId, "- will setSession when ready!");
var otDoc = documents[docId] || new OTDocument(docId, doc);
if (aceSession)
otDoc.setSession(aceSession);
if (progress)
setupProgressCallback(otDoc, progress);
if (documents[docId])
return console.warn("[OT] Document previously joined -", docId,
"STATE: loading:", otDoc.loading, "loaded:", otDoc.loaded, "inited:", otDoc.inited);
documents[docId] = otDoc;
// test late join - document syncing - best effort
if (connect.connected)
otDoc.load();
return otDoc;
}
function setupProgressCallback(otDoc, progress) {
otDoc.on("joinProgress", function(e) {
progress && progress(e.loaded, e.total, e.complete);
});
}
function leaveDocument(docId) {
if (!docId || !documents[docId] || !connect.connected)
return;
console.log("[OT] Leave", docId);
var doc = documents[docId];
doc.leave(); // will also dispose
delete documents[docId];
}
function reportLargeDocument(doc, forceReadonly) {
var docId = doc.id;
delete documents[doc.id];
if (workspace.isAdmin && !forceReadonly) {
if (workspace.onlineCount === 1)
return console.log("File is very large, collaborative editing disabled: " + docId);
return showError("File is very large, collaborative editing disabled: " + docId, 5000);
}
showError("File is very large. Collaborative editing disabled: " + docId, 5000);
var tab = tabManager.findTab(docId);
if (!tab || !tab.editor)
return;
tab.classList.add("error");
if (doc.readonly)
return;
doc.readonly = true;
}
function reportDocChangedOnDisk(path) {
emit("change", {
path: path,
type: "change",
});
}
function reportDocHasPendingChanges(path) {
var tab = tabManager.findTab(path);
if (tab) {
setTimeout(function() {
// Make the tab show as unsaved
tab.document.undoManager.bookmark(-2);
}, 50);
}
}
function saveDocument(docId, fallbackFn, fallbackArgs, callback) {
var doc = documents[docId];
var joinError;
clearTimeout(saveFallbackTimeouts[docId]);
saveFallbackTimeouts[docId] = setTimeout(function() {
console.warn("[OT] collab saveFallbackTimeout while trying to save file", docId, "- trying fallback approach instead");
fsSaveFallback();
doc.off("saved", onSaved);
failedSaveAttempts++;
emit("saveFallbackStart", { path: docId });
}, SAVE_FILESYSTEM_FALLBACK_TIMEOUT * Math.pow(2, -Math.min(failedSaveAttempts, 5)));
function onSaved(e) {
doc.off("saved", onSaved);
clearTimeout(saveFallbackTimeouts[docId]);
if (e.err) {
if ((e.code == "ETIMEOUT" || e.code == "EMISMATCH") && fallbackFn) {
// The vfs socket is probably dead ot stale
console.warn("[OT] collab error:", e.code, "trying to save file", docId, "- trying fallback approach instead");
return fsSaveFallback({ code: e.code, err: e.err });
} else {
sendSaveError({ code: e.code, err: e.err }, "Collab saving failed on unexpected error");
}
} else {
failedSaveAttempts = 0;
}
callback(e.err);
}
function fsSaveFallback(attempt) {
var message = doc && doc.loaded
? "Warning: using fallback saving on loaded document"
: "Warning: using fallback saving on unloaded document";
if (doc && doc.saveStateDebugging) {
message = "Warning: using fallback saving due to save timeout";
}
console.warn(message, attempt);
fallbackFn.apply(null, fallbackArgs);
}
function sendSaveError(attempt, message) {
errorHandler.reportError(new Error(message), {
docId: docId,
loading: doc && doc.loading,
loaded: doc && doc.loaded,
inited: doc && doc.inited,
rejoinReason: doc && doc.rejoinReason,
state: doc && doc.state,
stateWhenSaveCalled: doc && doc.stateWhenSaveCalled,
saveStateDebugging: doc && doc.saveStateDebugging,
joinError: joinError,
connected: connect.connected,
attempt: attempt,
failedSaveAttempts: failedSaveAttempts
}, ["collab"]);
}
if (!doc.loaded || !connect.connected) {
if (connect.connected && !doc.loaded && !doc.loading) {
// broken state we are not joined and not trying to join
clearTimeout(saveFallbackTimeouts[docId]);
fsSaveFallback("document not joined and not trying to join");
return;
}
doc.once("joined", function(e) {
joinError = e && e.err;
if (e && !e.err)
doCollabSave();
});
}
else {
doCollabSave();
}
function doCollabSave() {
doc.on("saved", onSaved);
doc.save();
}
}
function leaveAll() {
Object.keys(documents).forEach(function(docId) {
leaveDocument(docId);
});
}
function refreshActiveDocuments() {
for (var docId in documents) {
var doc = documents[docId];
var tab = doc.original.tab;
if (tab.pane.activeTab === tab && doc.inited)
doc.authorLayer.refresh();
}
}
/*
function getDocumentsWithinPath(path) {
var docIds = Object.keys(documents);
var docs = [];
for (var i = 0, l = docIds.length; i < l; ++i) {
var doc = documents[docIds[i]];
if (doc.id.indexOf(path) === 0)
docs.push(doc);
}
return docs;
};
*/
/**
* Start a Collab session with each metadata read.
*
* e.path
* e.tab
* e.callback
* e.progress
*/
function beforeReadFile(e) {
var path = e.path;
var progress = e.progress;
var callback = e.callback;
if (!path || e.tab.editorType != "ace")
return;
var otDoc = documents[path];
if (!otDoc)
otDoc = documents[path] = joinDocument(path, e.tab.document, progress);
else
setupProgressCallback(otDoc, progress);
otDoc.on("joined", onJoined);
otDoc.on("largeDocument", reportLargeDocument.bind(null, otDoc));
otDoc.on("joinProgress", startWatchDog);
otDoc.on("beforeSave", function(e) {
emit("beforeSave", e);
});
// Load using XHR while collab not connected
if (!connect.connected) {
// Someone else listening to beforeReadFile
// will have to call our callback
callback = null;
return;
}
startWatchDog();
var fallbackXhrAbort;
return {
abort: function() {
if (fallbackXhrAbort)
fallbackXhrAbort();
else
console.log("TODO: [OT] abort joining a document");
}
};
function startWatchDog() {
clearTimeout(openFallbackTimeouts[path]);
openFallbackTimeouts[path] = setTimeout(function() {
console.warn("[OT] JOIN_DOC timed out:", path, "- fallback to filesystem, but don't abort");
fsOpenFallback();
otDoc.off("joined", onJoined);
}, OPEN_FILESYSTEM_FALLBACK_TIMEOUT);
}
function onJoined(e) {
otDoc.off("joined", onJoined);
clearTimeout(openFallbackTimeouts[path]);
openFallbackTimeouts[path] = null;
if (e.err) {
if (e.err.code != "ENOENT" && e.err.code != "ELARGE")
console.warn("[OT] JOIN_DOC failed:", path, "- fallback to filesystem");
return fsOpenFallback();
}
console.log("[OT] Joined", otDoc.id);
callback && callback(e.err, e.contents, e.metadata);
}
function fsOpenFallback() {
var xhr = fs.readFileWithMetadata(path, "utf8", callback || function() {}, progress) || {};
fallbackXhrAbort = ((xhr && xhr.abort) || function() {}).bind(xhr);
}
}
/**
* Normalize tab contents after file read.
*/
function afterReadFile(e) {
var path = e.path;
var tab = tabManager.findTab(path);
var doc = documents[path];
if (!tab || !doc || doc.loaded)
return;
var httpLoadedValue = tab.document.value;
var normHttpValue = collabUtil.normalizeTextLT(httpLoadedValue);
if (httpLoadedValue !== normHttpValue)
tab.document.value = normHttpValue;
}
/**
* Save using collab server for OT-enabled documents.
* Overrides the normal file writing behavior.
*/
function beforeWriteFile(e) {
var path = e.path;
var tab = tabManager.findTab(path);
var doc = documents[path];
// Fall back to default writeFile if not applicable
if (!doc || !tab)
return;
if (timeslider.visible)
return false;
// Override default writeFile
var args = e.args.slice();
var progress = args.pop();
var callback = args.pop();
var defaultWriteFile = e.fn;
saveDocument(path, defaultWriteFile, e.args, callback);
return false;
}
function onSetPath(tab, oldpath, path) {
console.log("[OT] detected rename/save as from", oldpath, "to", path);
leaveDocument(oldpath);
joinDocument(path, tab.document);
// TODO this is flaky, there should be rename command in the server
documents[path].once("joined", function(e) {
if (e.err) {
leaveDocument(oldpath);
setTimeout(joinDocument(path, tab.document), SAVE_FILESYSTEM_FALLBACK_TIMEOUT_REPEATED);
}
});
}
function bubbleNotification(msg, user) {
if (!user)
return bubble.popup(msg);
var md5Email = user.md5Email;
console.log("Collab:", user.fullname, msg);
bubble.popup([
["img", { width: 26, height: 26, class: "gravatar-image",
src: "https://secure.gravatar.com/avatar/" + md5Email + "?s=26&d=retro" }],
["span", null, user.fullname, ["span", { class: "notification_sub" }, msg]]
]);
}
/***** sync tabs *****/
function getTabState(tabId) {
var tab;
if (tabId) {
tabManager.getTabs().some(function(t) {
if (getTabId(t) == tabId) {
return (tab = t);
}
});
} else {
tab = tabManager.focussedTab;
}
if (!tab) return;
var state = tab.getState();
var doc = state.document;
if (doc) {
doc.value = doc.undoManager = doc.meta = undefined;
if (doc.ace) {
// doc.ace.folds = doc.ace.options = undefined;
doc.ace = { selection: doc.ace.selection };
}
}
state.className = undefined;
return state;
}
function getTabId(tab) {
if (!tab || !tab.document)
return;
var meta = tab.document.meta;
if (meta.preview || meta.newfile)
return;
if (tab.editorType == "terminal")
return "terminal:" + (tab.document.getState().terminal || {}).id;
if (tab.editorType == "output") {
var state = tab.document.getState().output;
var config = state.config || {};
return "run-config:" + (config.name || state.id);
}
if (tab.editorType == "preview")
return tab.name;
if (tab.path)
return tab.path;
}
var lastJump;
function revealUser(clientId, tabId) {
if (clientId == workspace.myClientId) {
handleUserMessage({
action: "open",
target: clientId,
tabState: lastJump
});
} else {
connect.send("MESSAGE", {
source: workspace.myClientId,
target: clientId,
action: "getTab",
tabId: tabId
});
}
}
function listOpenFiles(clientId) {
connect.send("MESSAGE", {
source: workspace.myClientId,
target: clientId,
action: "listOpenFiles"
});
}
function handleUserMessage(data) {
if (data.action == "getTab") {
if (data.target == workspace.myClientId) {
connect.send("MESSAGE", {
source: workspace.myClientId,
target: data.source,
action: "open",
tabState: getTabState(data.tabId),
});
}
} else if (data.action == "open") {
if (data.target == workspace.myClientId) {
if (data.tabState) {
var tabState = getTabState() || {};
if (shouldUpdateLastJump(lastJump, tabState, data.tabState))
lastJump = tabState;
data.tabState.focus = true;
if (data.tabState.document)
delete data.tabState.document.filter;
tabManager.open(data.tabState);
}
}
} else if (data.action == "listOpenFiles") {
if (data.fileList) {
workspace.updateOpenDocs({
clientId: data.source,
userId: data.userId,
fileList: data.fileList
}, "set");
} else if (data.target == workspace.myClientId) {
var openFiles = tabManager.getTabs().map(function(tab) {
return getTabId(tab);
}).filter(Boolean);
var active = openFiles.indexOf(getTabId(tabManager.focussedTab));
connect.send("MESSAGE", {
userId: workspace.myUserId,
source: workspace.myClientId,
target: data.source,
action: "listOpenFiles",
fileList: {
active: active,
documents: openFiles
}
});
}
}
}
function shouldUpdateLastJump(prevState, state, newState) {
function getSelection(s) {
return s && s.document && s.document.ace && s.document.ace.selection;
}
if (!prevState) return true;
if (!newState.name) return false;
var prevSel = JSON.stringify(getSelection(prevState));
var sel = JSON.stringify(getSelection(state));
var newSel = JSON.stringify(getSelection(newState));
if (prevState.name == state.name && newSel == sel) {
return false;
}
if (state.name == newState.name && newSel == sel)
return false;
return true;
}
function scheduleUpdateUserBadges() {
if (scheduleUpdateUserBadges.timer) return;
scheduleUpdateUserBadges.timer = setTimeout(function() {
scheduleUpdateUserBadges.timer = null;
updateUserBadges();
}, 10);
}
function updateUserBadges() {
var users = workspace.users;
var myId = workspace.myUserId;
Object.keys(users).forEach(function(id) {
if (id == myId) return;
var user = users[id];
if (!user.online)
return menus.remove("user_" + id);
if (menus.getMenuId("user_" + id))
return;
addButton(id, user.fullname, user.md5Email);
});
function addButton(uid, name, md5Email) {
menus.remove("user_" + uid);
var parent = layout.getElement("barExtras");
// Create Menu
var mnuUser = new ui.menu();
plugin.addElement(mnuUser);
// Add named button
var icon = util.getGravatarUrl(md5Email, 32, "");
menus.addItemByPath("user_" + uid + "/", mnuUser, 110000, plugin);
// Add sub menu items
var c = 500;
menus.addItemByPath("user_" + uid + "/Open Active File", new ui.item({
onclick: function() {
var user = workspace.users[uid];
revealUser(user.clients[0]);
}
}), c += 100, plugin);
var button = menus.get("user_" + uid).item;
button.setAttribute("class", "btnName");
button.setAttribute("icon", icon);
button.setAttribute("iconsize", "16px 16px");
button.setAttribute("tooltip", name);
if (options.showFullNameInMenuBar) {
button.setAttribute("caption", name);
}
if (button.$ext)
button.$ext.style.color = collabUtil.formatColor(workspace.colorPool[uid]);
ui.insertByIndex(parent, button, 550, plugin);
}
}
/***** Lifecycle *****/
plugin.on("newListener", function(event, listener) {
if (event == "connect" && connect.connected)
listener();
});
plugin.on("load", function() {
load();
});
plugin.on("draw", function(e) {
draw(e);
});
plugin.on("unload", function() {
loaded = false;
drawn = true;
});
/**
* @singleton
*/
plugin.freezePublicAPI({
_events: [
/**
* Fires when the collab panel is first drawn to enable sub-collab-panels to listen and render correctly
* @event drawPanels
* @param {Object} e
* @param {HTMLElement} e.html the html element to build collan panels on top of
* @param {AMLElement} e.aml the apf element to build collan panels on top of
*/
"drawPanels",
/**
* Fires when the collab is connected and the collab workspace is synced
* @event connect
*/
"connect",
/**
* Fires when a chat message arrives (the chat plugin should listen to it to get chat messages)
* @event chatMessage
* @param {Object} e
* @param {String} e.userId the chat message author user id
* @param {String} e.text the chat text to diaplay
* @param {Boolean} e.increment should the chat counter be incremented (not yet implemented)
*/
"chatMessage",
"beforeSave",
"message"
],
/**
* Get a clone of open collab documents
* @property {Object} documents
*/
get documents() { return util.cloneObject(documents); },
/**
* Specifies whether the collab is connected or not
* @property {Boolean} connected
*/
get connected() { return connect.connected; },
/**
* Specifies whether the collab debug is enabled or not
* @property {Boolean} debug
*/
get debug() { return connect.debug; },
/**
* Get the open collab document with path
* @param {String} path the file path of the document
* @return {OTDocument} the collab document open with this path
*/
getDocument: function (path) { return documents[path]; },
/**
* Send a message to the collab server
* @param {String} type the type of the message
* @param {Object} message the message body to send
*/
send: function(type, message) { connect.send(type, message); },
/**
* @ignore
*/
revealUser: revealUser,
listOpenFiles: listOpenFiles,
leaveDocument: leaveDocument,
joinDocument: joinDocument,
});
register(null, {
"collab": plugin
});
}
});