diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5d7098ca..30011e081 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
### Notable enhancements and fixes
+* New `integratedChat` setting makes it possible to completely disable the
+ built-in chat feature (not just hide it).
* Improvements to login session management:
* `express_sid` cookies and `sessionstorage:*` database records are no longer
created unless `requireAuthentication` is `true` (or a plugin causes them to
@@ -55,6 +57,8 @@
* New APIs for processing attributes: `ep_etherpad-lite/static/js/attributes`
(low-level API) and `ep_etherpad-lite/static/js/AttributeMap` (high-level
API).
+* The `handleClientMessage_${name}` client-side hooks are now passed the raw
+ message object in the new `msg` context property.
* The `import` server-side hook has a new `ImportError` context property.
* New `exportEtherpad` and `importEtherpad` server-side hooks.
* The `handleMessageSecurity` and `handleMessage` server-side hooks have a new
@@ -89,6 +93,18 @@
instead.
* `padUpdate`: The `author` context property is deprecated; use the new
`authorId` context property instead. Also, the hook now runs asynchronously.
+* Chat API deprecations and removals (no replacements planned):
+ * Server-side:
+ * The `Pad.appendChatMessage()` method is deprecated.
+ * The `Pad.getChatMessage()` method is deprecated.
+ * The `Pad.getChatMessages()` method is deprecated.
+ * The `sendChatMessageToPadClients()` function in
+ `src/node/handler/PadMessageHandler.js` is deprecated.
+ * Client-side:
+ * The `chat` global variable is deprecated.
+ * The `chat` export in `src/static/js/chat.js` is deprecated.
+ * The `pad.determineChatVisibility()` method was removed.
+ * The `pad.determineChatAndUsersVisibility()` method was removed.
* Returning `true` from a `handleMessageSecurity` hook function is deprecated;
return `'permitOnce'` instead.
* Changes to the `src/static/js/Changeset.js` library:
diff --git a/doc/api/hooks_client-side.md b/doc/api/hooks_client-side.md
index 45ef18a01..cd4d22843 100755
--- a/doc/api/hooks_client-side.md
+++ b/doc/api/hooks_client-side.md
@@ -393,15 +393,10 @@ This hook is called after the content of a node is collected by the usual
methods. The cc object can be used to do a bunch of things that modify the
content of the pad. See, for example, the heading1 plugin for etherpad original.
-## handleClientMessage_`name`
+## `handleClientMessage_${name}`
Called from: `src/static/js/collab_client.js`
-Things in context:
-
-1. payload - the data that got sent with the message (use it for custom message
- content)
-
This hook gets called every time the client receives a message of type `name`.
This can most notably be used with the new HTTP API call, "sendClientsMessage",
which sends a custom message type to all clients connected to a pad. You can
@@ -410,6 +405,12 @@ also use this to handle existing types.
`collab_client.js` has a pretty extensive list of message types, if you want to
take a look.
+Context properties:
+
+ * `msg`: The raw message object.
+ * `payload`: The data that got sent with the message. Usually this is
+ `msg.payload`.
+
## aceStartLineAndCharForPoint-aceEndLineAndCharForPoint
Called from: src/static/js/ace2_inner.js
diff --git a/doc/api/hooks_server-side.md b/doc/api/hooks_server-side.md
index 5998c247e..d786fdc5c 100644
--- a/doc/api/hooks_server-side.md
+++ b/doc/api/hooks_server-side.md
@@ -1086,7 +1086,7 @@ exports.userLeave = async (hookName, {author, padId}) => {
## `chatNewMessage`
-Called from: `src/node/handler/PadMessageHandler.js`
+Called from: `src/node/chat.js`
Called when a user (or plugin) generates a new chat message, just before it is
saved to the pad and relayed to all connected users.
diff --git a/doc/docker.md b/doc/docker.md
index f72c4dd66..7e57feb44 100644
--- a/doc/docker.md
+++ b/doc/docker.md
@@ -80,15 +80,16 @@ The `settings.json.docker` available by default allows to control almost every s
### General
-| Variable | Description | Default |
-| ------------------ | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `TITLE` | The name of the instance | `Etherpad` |
-| `FAVICON` | favicon default name, or a fully specified URL to your own favicon | `favicon.ico` |
-| `DEFAULT_PAD_TEXT` | The default text of a pad | `Welcome to Etherpad! This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents! Get involved with Etherpad at https://etherpad.org` |
-| `IP` | IP which etherpad should bind at. Change to `::` for IPv6 | `0.0.0.0` |
-| `PORT` | port which etherpad should bind at | `9001` |
-| `ADMIN_PASSWORD` | the password for the `admin` user (leave unspecified if you do not want to create it) | |
-| `USER_PASSWORD` | the password for the first user `user` (leave unspecified if you do not want to create it) | |
+| Variable | Description | Default |
+|--------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `TITLE` | The name of the instance | `Etherpad` |
+| `FAVICON` | favicon default name, or a fully specified URL to your own favicon | `favicon.ico` |
+| `DEFAULT_PAD_TEXT` | The default text of a pad | `Welcome to Etherpad! This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents! Get involved with Etherpad at https://etherpad.org` |
+| `INTEGRATED_CHAT` | Whether to enable the built-in chat feature. Set this to false if you prefer to use a plugin to provide chat functionality or simply do not want the feature. | true |
+| `IP` | IP which etherpad should bind at. Change to `::` for IPv6 | `0.0.0.0` |
+| `PORT` | port which etherpad should bind at | `9001` |
+| `ADMIN_PASSWORD` | the password for the `admin` user (leave unspecified if you do not want to create it) | |
+| `USER_PASSWORD` | the password for the first user `user` (leave unspecified if you do not want to create it) | |
### Database
diff --git a/settings.json.docker b/settings.json.docker
index 725af9f31..58a56affc 100644
--- a/settings.json.docker
+++ b/settings.json.docker
@@ -223,6 +223,13 @@
*/
"defaultPadText" : "${DEFAULT_PAD_TEXT:Welcome to Etherpad!\n\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\n\nGet involved with Etherpad at https:\/\/etherpad.org\n}",
+ /*
+ * Whether to enable the built-in chat feature. Set this to false if you
+ * prefer to use a plugin to provide chat functionality or simply do not want
+ * the feature.
+ */
+ "integratedChat": "${INTEGRATED_CHAT:true}",
+
/*
* Default Pad behavior.
*
@@ -231,6 +238,7 @@
"padOptions": {
"noColors": "${PAD_OPTIONS_NO_COLORS:false}",
"showControls": "${PAD_OPTIONS_SHOW_CONTROLS:true}",
+ // To completely disable chat, set integratedChat to false.
"showChat": "${PAD_OPTIONS_SHOW_CHAT:true}",
"showLineNumbers": "${PAD_OPTIONS_SHOW_LINE_NUMBERS:true}",
"useMonospaceFont": "${PAD_OPTIONS_USE_MONOSPACE_FONT:false}",
diff --git a/settings.json.template b/settings.json.template
index b2cb9555a..59e6c2e0a 100644
--- a/settings.json.template
+++ b/settings.json.template
@@ -224,6 +224,13 @@
*/
"defaultPadText" : "Welcome to Etherpad!\n\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\n\nGet involved with Etherpad at https:\/\/etherpad.org\n",
+ /*
+ * Whether to enable the built-in chat feature. Set this to false if you
+ * prefer to use a plugin to provide chat functionality or simply do not want
+ * the feature.
+ */
+ "integratedChat": true,
+
/*
* Default Pad behavior.
*
@@ -232,6 +239,7 @@
"padOptions": {
"noColors": false,
"showControls": true,
+ // To completely disable chat, set integratedChat to false.
"showChat": true,
"showLineNumbers": true,
"useMonospaceFont": false,
diff --git a/src/ep.json b/src/ep.json
index ec09696c5..4c78e2e0d 100644
--- a/src/ep.json
+++ b/src/ep.json
@@ -12,6 +12,33 @@
"shutdown": "ep_etherpad-lite/node/utils/Minify"
}
},
+ {
+ "name": "chat",
+ "client_hooks": {
+ "aceKeyEvent": "ep_etherpad-lite/static/js/chat",
+ "handleClientMessage_CHAT_MESSAGE": "ep_etherpad-lite/static/js/chat",
+ "handleClientMessage_CHAT_MESSAGES": "ep_etherpad-lite/static/js/chat",
+ "postAceInit": "ep_etherpad-lite/static/js/chat"
+ },
+ "hooks": {
+ "clientVars": "ep_etherpad-lite/node/chat",
+ "eejsBlock_mySettings": "ep_etherpad-lite/node/chat",
+ "eejsBlock_stickyContainer": "ep_etherpad-lite/node/chat",
+ "exportEtherpad": "ep_etherpad-lite/node/chat",
+ "handleMessage": "ep_etherpad-lite/node/chat",
+ "importEtherpad": "ep_etherpad-lite/node/chat",
+ "padCheck": "ep_etherpad-lite/node/chat",
+ "padCopy": "ep_etherpad-lite/node/chat",
+ "padLoad": "ep_etherpad-lite/node/chat",
+ "padRemove": "ep_etherpad-lite/node/chat"
+ }
+ },
+ {
+ "name": "chatAlwaysLoaded",
+ "hooks": {
+ "socketio": "ep_etherpad-lite/node/chat"
+ }
+ },
{
"name": "express",
"hooks": {
diff --git a/src/node/chat.js b/src/node/chat.js
new file mode 100644
index 000000000..ec1d6ce8e
--- /dev/null
+++ b/src/node/chat.js
@@ -0,0 +1,324 @@
+'use strict';
+
+const ChatMessage = require('../static/js/ChatMessage');
+const CustomError = require('./utils/customError');
+const Stream = require('./utils/Stream');
+const api = require('./db/API');
+const assert = require('assert').strict;
+const authorManager = require('./db/AuthorManager');
+const hooks = require('../static/js/pluginfw/hooks.js');
+const pad = require('./db/Pad');
+const padManager = require('./db/PadManager');
+const padMessageHandler = require('./handler/PadMessageHandler');
+const settings = require('./utils/Settings');
+
+let socketio;
+
+const appendChatMessage = async (pad, msg) => {
+ if (!settings.integratedChat) {
+ throw new Error('integrated chat is disabled (see integratedChat in settings.json)');
+ }
+ pad.chatHead++;
+ await Promise.all([
+ // Don't save the display name in the database because the user can change it at any time. The
+ // `displayName` property will be populated with the current value when the message is read from
+ // the database.
+ pad.db.set(`pad:${pad.id}:chat:${pad.chatHead}`, {...msg, displayName: undefined}),
+ pad.saveToDatabase(),
+ ]);
+};
+
+const getChatMessage = async (pad, entryNum) => {
+ if (!settings.integratedChat) {
+ throw new Error('integrated chat is disabled (see integratedChat in settings.json)');
+ }
+ const entry = await pad.db.get(`pad:${pad.id}:chat:${entryNum}`);
+ if (entry == null) return null;
+ const message = ChatMessage.fromObject(entry);
+ message.displayName = await authorManager.getAuthorName(message.authorId);
+ return message;
+};
+
+const getChatMessages = async (pad, start, end) => {
+ if (!settings.integratedChat) {
+ throw new Error('integrated chat is disabled (see integratedChat in settings.json)');
+ }
+ const entries = await Promise.all(
+ [...Array(end + 1 - start).keys()].map((i) => getChatMessage(pad, start + i)));
+
+ // sort out broken chat entries
+ // it looks like in happened in the past that the chat head was
+ // incremented, but the chat message wasn't added
+ return entries.filter((entry) => {
+ const pass = (entry != null);
+ if (!pass) {
+ console.warn(`WARNING: Found broken chat entry in pad ${pad.id}`);
+ }
+ return pass;
+ });
+};
+
+const sendChatMessageToPadClients = async (message, padId) => {
+ if (!settings.integratedChat) {
+ throw new Error('integrated chat is disabled (see integratedChat in settings.json)');
+ }
+ const pad = await padManager.getPad(padId, null, message.authorId);
+ await hooks.aCallAll('chatNewMessage', {message, pad, padId});
+ // appendChatMessage() ignores the displayName property so we don't need to wait for
+ // authorManager.getAuthorName() to resolve before saving the message to the database.
+ const promise = appendChatMessage(pad, message);
+ message.displayName = await authorManager.getAuthorName(message.authorId);
+ socketio.sockets.in(padId).json.send({
+ type: 'COLLABROOM',
+ data: {type: 'CHAT_MESSAGE', message},
+ });
+ await promise;
+};
+
+exports.clientVars = (hookName, {pad: {chatHead}}) => ({chatHead});
+
+exports.eejsBlock_mySettings = (hookName, context) => {
+ if (!settings.integratedChat) return;
+ context.content += `
+
+
+
+
+
+
+
+
+ `;
+};
+
+exports.eejsBlock_stickyContainer = (hookName, context) => {
+ if (!settings.integratedChat) return;
+ /* eslint-disable max-len */
+ context.content += `
+
+
+
+ 0
+
+
+ `;
+ /* eslint-enable max-len */
+};
+
+exports.exportEtherpad = async (hookName, {pad, data, dstPadId}) => {
+ const ops = (function* () {
+ const {chatHead = -1} = pad;
+ data[`pad:${dstPadId}`].chatHead = chatHead;
+ for (let i = 0; i <= chatHead; ++i) {
+ yield (async () => {
+ const v = await pad.db.get(`pad:${pad.id}:chat:${i}`);
+ if (v == null) return;
+ data[`pad:${dstPadId}:chat:${i}`] = v;
+ })();
+ }
+ })();
+ for (const op of new Stream(ops).batch(100).buffer(99)) await op;
+};
+
+exports.handleMessage = async (hookName, {message, sessionInfo, socket}) => {
+ if (!settings.integratedChat) return;
+ const {authorId, padId, readOnly} = sessionInfo;
+ if (message.type !== 'COLLABROOM' || readOnly) return;
+ switch (message.data.type) {
+ case 'CHAT_MESSAGE': {
+ const chatMessage = ChatMessage.fromObject(message.data.message);
+ // Don't trust the user-supplied values.
+ chatMessage.time = Date.now();
+ chatMessage.authorId = authorId;
+ await sendChatMessageToPadClients(chatMessage, padId);
+ break;
+ }
+ case 'GET_CHAT_MESSAGES': {
+ const {start, end} = message.data;
+ if (!Number.isInteger(start)) throw new Error(`missing or invalid start: ${start}`);
+ if (!Number.isInteger(end)) throw new Error(`missing or invalid end: ${end}`);
+ const count = end - start;
+ if (count < 0 || count > 100) throw new Error(`invalid number of messages: ${count}`);
+ const pad = await padManager.getPad(padId, null, authorId);
+ socket.json.send({
+ type: 'COLLABROOM',
+ data: {
+ type: 'CHAT_MESSAGES',
+ messages: await getChatMessages(pad, start, end),
+ },
+ });
+ break;
+ }
+ default:
+ return;
+ }
+ return null; // Important! Returning null (not undefined!) stops further processing.
+};
+
+exports.importEtherpad = async (hookName, {pad, data, srcPadId}) => {
+ const ops = (function* () {
+ const {chatHead = -1} = data[`pad:${srcPadId}`];
+ pad.chatHead = chatHead;
+ for (let i = 0; i <= chatHead; ++i) {
+ const v = data[`pad:${srcPadId}:chat:${i}`];
+ if (v == null) continue;
+ yield pad.db.set(`pad:${pad.id}:chat:${i}`, v);
+ }
+ })();
+ for (const op of new Stream(ops).batch(100).buffer(99)) await op;
+};
+
+exports.padCheck = async (hookName, {pad}) => {
+ assert(pad.chatHead != null);
+ assert(Number.isInteger(pad.chatHead));
+ assert(pad.chatHead >= -1);
+ const chats = Stream.range(0, pad.chatHead).map(async (c) => {
+ try {
+ const msg = await getChatMessage(pad, c);
+ assert(msg != null);
+ assert(msg instanceof ChatMessage);
+ } catch (err) {
+ err.message = `(pad ${pad.id} chat message ${c}) ${err.message}`;
+ throw err;
+ }
+ });
+ for (const p of chats.batch(100).buffer(99)) await p;
+};
+
+exports.padCopy = async (hookName, {srcPad, dstPad}) => {
+ const {chatHead = -1} = srcPad;
+ dstPad.chatHead = chatHead;
+ const copyChat = async (i) => {
+ const val = await srcPad.db.get(`pad:${srcPad.id}:chat:${i}`);
+ await dstPad.db.set(`pad:${dstPad.id}:chat:${i}`, val);
+ };
+ const ops = (function* () {
+ for (let i = 0; i <= chatHead; ++i) yield copyChat(i);
+ })();
+ for (const op of new Stream(ops).batch(100).buffer(99)) await op;
+};
+
+exports.padLoad = async (hookName, {pad}) => {
+ if (!('chatHead' in pad)) pad.chatHead = -1;
+};
+
+exports.padRemove = async (hookName, {pad}) => {
+ const ops = (function* () {
+ const {chatHead = -1} = pad;
+ for (let i = 0; i <= chatHead; ++i) yield pad.db.remove(`pad:${pad.id}:chat:${i}`);
+ })();
+ for (const op of new Stream(ops).batch(100).buffer(99)) await op;
+};
+
+exports.socketio = (hookName, {io}) => {
+ socketio = io;
+};
+
+const getPadSafe = async (padId) => {
+ if (typeof padId !== 'string') throw new CustomError('padID is not a string', 'apierror');
+ if (!padManager.isValidPadId(padId)) throw new CustomError('padID is not valid', 'apierror');
+ if (!await padManager.doesPadExist(padId)) throw new CustomError('pad not found', 'apierror');
+ return await padManager.getPad(padId);
+};
+
+api.registerChatHandlers({
+ /**
+ * appendChatMessage(padId, text, authorId, time), creates a chat message for the pad id,
+ * time is a timestamp
+ *
+ * Example returns:
+ *
+ * {code: 0, message:"ok", data: null}
+ * {code: 1, message:"padID does not exist", data: null}
+ */
+ appendChatMessage: async (padId, text, authorId, time) => {
+ if (!settings.integratedChat) {
+ throw new Error('integrated chat is disabled (see integratedChat in settings.json)');
+ }
+ if (typeof text !== 'string') throw new CustomError('text is not a string', 'apierror');
+ if (time === undefined || !Number.isInteger(Number.parseFloat(time))) time = Date.now();
+ await sendChatMessageToPadClients(new ChatMessage(text, authorId, time), padId);
+ },
+
+ /**
+ * getChatHead(padId) returns the chatHead (last number of the last chat-message) of the pad
+ *
+ * Example returns:
+ *
+ * {code: 0, message:"ok", data: {chatHead: 42}}
+ * {code: 1, message:"padID does not exist", data: null}
+ */
+ getChatHead: async (padId) => {
+ if (!settings.integratedChat) {
+ throw new Error('integrated chat is disabled (see integratedChat in settings.json)');
+ }
+ const pad = await getPadSafe(padId);
+ const {chatHead = -1} = pad;
+ return {chatHead};
+ },
+
+ /**
+ * getChatHistory(padId, start, end), returns a part of or the whole chat-history of this pad
+ *
+ * Example returns:
+ *
+ * {"code":0,"message":"ok","data":{"messages":[
+ * {"text":"foo","authorID":"a.foo","time":1359199533759,"userName":"test"},
+ * {"text":"bar","authorID":"a.foo","time":1359199534622,"userName":"test"}
+ * ]}}
+ *
+ * {code: 1, message:"start is higher or equal to the current chatHead", data: null}
+ *
+ * {code: 1, message:"padID does not exist", data: null}
+ */
+ getChatHistory: async (padId, start, end) => {
+ if (!settings.integratedChat) {
+ throw new Error('integrated chat is disabled (see integratedChat in settings.json)');
+ }
+ if (start && end) {
+ if (start < 0) throw new CustomError('start is below zero', 'apierror');
+ if (end < 0) throw new CustomError('end is below zero', 'apierror');
+ if (start > end) throw new CustomError('start is higher than end', 'apierror');
+ }
+ const pad = await getPadSafe(padId);
+ const {chatHead = -1} = pad;
+ if (!start || !end) {
+ start = 0;
+ end = chatHead;
+ }
+ if (start > chatHead) {
+ throw new CustomError('start is higher than the current chatHead', 'apierror');
+ }
+ if (end > chatHead) {
+ throw new CustomError('end is higher than the current chatHead', 'apierror');
+ }
+ return {messages: await getChatMessages(pad, start, end)};
+ },
+});
+
+pad.registerLegacyChatMethodHandlers({
+ appendChatMessage,
+ getChatMessage,
+ getChatMessages,
+});
+
+padMessageHandler.registerLegacyChatHandlers({
+ sendChatMessageToPadClients,
+});
diff --git a/src/node/db/API.js b/src/node/db/API.js
index 9b2ecadc7..b10aae06a 100644
--- a/src/node/db/API.js
+++ b/src/node/db/API.js
@@ -20,7 +20,6 @@
*/
const Changeset = require('../../static/js/Changeset');
-const ChatMessage = require('../../static/js/ChatMessage');
const CustomError = require('../utils/customError');
const padManager = require('./PadManager');
const padMessageHandler = require('../handler/PadMessageHandler');
@@ -289,82 +288,7 @@ exports.setHTML = async (padID, html, authorId = '') => {
* CHAT FUNCTIONS *
**************** */
-/**
-getChatHistory(padId, start, end), returns a part of or the whole chat-history of this pad
-
-Example returns:
-
-{"code":0,"message":"ok","data":{"messages":[
- {"text":"foo","authorID":"a.foo","time":1359199533759,"userName":"test"},
- {"text":"bar","authorID":"a.foo","time":1359199534622,"userName":"test"}
-]}}
-
-{code: 1, message:"start is higher or equal to the current chatHead", data: null}
-
-{code: 1, message:"padID does not exist", data: null}
-*/
-exports.getChatHistory = async (padID, start, end) => {
- if (start && end) {
- if (start < 0) {
- throw new CustomError('start is below zero', 'apierror');
- }
- if (end < 0) {
- throw new CustomError('end is below zero', 'apierror');
- }
- if (start > end) {
- throw new CustomError('start is higher than end', 'apierror');
- }
- }
-
- // get the pad
- const pad = await getPadSafe(padID, true);
-
- const chatHead = pad.chatHead;
-
- // fall back to getting the whole chat-history if a parameter is missing
- if (!start || !end) {
- start = 0;
- end = pad.chatHead;
- }
-
- if (start > chatHead) {
- throw new CustomError('start is higher than the current chatHead', 'apierror');
- }
- if (end > chatHead) {
- throw new CustomError('end is higher than the current chatHead', 'apierror');
- }
-
- // the the whole message-log and return it to the client
- const messages = await pad.getChatMessages(start, end);
-
- return {messages};
-};
-
-/**
-appendChatMessage(padID, text, authorID, time), creates a chat message for the pad id,
-time is a timestamp
-
-Example returns:
-
-{code: 0, message:"ok", data: null}
-{code: 1, message:"padID does not exist", data: null}
-*/
-exports.appendChatMessage = async (padID, text, authorID, time) => {
- // text is required
- if (typeof text !== 'string') {
- throw new CustomError('text is not a string', 'apierror');
- }
-
- // if time is not an integer value set time to current timestamp
- if (time === undefined || !isInt(time)) {
- time = Date.now();
- }
-
- // @TODO - missing getPadSafe() call ?
-
- // save chat message to database and send message to all connected clients
- await padMessageHandler.sendChatMessageToPadClients(new ChatMessage(text, authorID, time), padID);
-};
+exports.registerChatHandlers = (handlers) => Object.assign(exports, handlers);
/* ***************
* PAD FUNCTIONS *
@@ -732,20 +656,6 @@ Example returns:
exports.checkToken = async () => {
};
-/**
-getChatHead(padID) returns the chatHead (last number of the last chat-message) of the pad
-
-Example returns:
-
-{code: 0, message:"ok", data: {chatHead: 42}}
-{code: 1, message:"padID does not exist", data: null}
-*/
-exports.getChatHead = async (padID) => {
- // get the pad
- const pad = await getPadSafe(padID, true);
- return {chatHead: pad.chatHead};
-};
-
/**
createDiffHTML(padID, startRev, endRev) returns an object of diffs from 2 points in a pad
@@ -851,7 +761,7 @@ const getPadSafe = async (padID, shouldExist, text, authorId = '') => {
}
// pad exists, let's get it
- return padManager.getPad(padID, text, authorId);
+ return await padManager.getPad(padID, text, authorId);
};
// checks if a rev is a legal number
diff --git a/src/node/db/Pad.js b/src/node/db/Pad.js
index b692962f1..35052a00e 100644
--- a/src/node/db/Pad.js
+++ b/src/node/db/Pad.js
@@ -22,6 +22,10 @@ const hooks = require('../../static/js/pluginfw/hooks');
const {padutils: {warnDeprecated}} = require('../../static/js/pad_utils');
const promises = require('../utils/promises');
+let chat = null;
+
+exports.registerLegacyChatMethodHandlers = (handlers) => chat = handlers;
+
/**
* Copied from the Etherpad source code. It converts Windows line breaks to Unix
* line breaks and convert Tabs to spaces
@@ -45,7 +49,6 @@ class Pad {
this.atext = Changeset.makeAText('\n');
this.pool = new AttributePool();
this.head = -1;
- this.chatHead = -1;
this.publicStatus = false;
this.id = id;
this.savedRevisions = [];
@@ -287,6 +290,7 @@ class Pad {
/**
* Adds a chat message to the pad, including saving it to the database.
*
+ * @deprecated
* @param {(ChatMessage|string)} msgOrText - Either a chat message object (recommended) or a
* string containing the raw text of the user's chat message (deprecated).
* @param {?string} [authorId] - The user's author ID. Deprecated; use `msgOrText.authorId`
@@ -295,31 +299,24 @@ class Pad {
* `msgOrText.time` instead.
*/
async appendChatMessage(msgOrText, authorId = null, time = null) {
+ warnDeprecated('Pad.appendChatMessage() is deprecated');
const msg =
msgOrText instanceof ChatMessage ? msgOrText : new ChatMessage(msgOrText, authorId, time);
- this.chatHead++;
- await Promise.all([
- // Don't save the display name in the database because the user can change it at any time. The
- // `displayName` property will be populated with the current value when the message is read
- // from the database.
- this.db.set(`pad:${this.id}:chat:${this.chatHead}`, {...msg, displayName: undefined}),
- this.saveToDatabase(),
- ]);
+ await chat.appendChatMessage(this, msg);
}
/**
+ * @deprecated
* @param {number} entryNum - ID of the desired chat message.
* @returns {?ChatMessage}
*/
async getChatMessage(entryNum) {
- const entry = await this.db.get(`pad:${this.id}:chat:${entryNum}`);
- if (entry == null) return null;
- const message = ChatMessage.fromObject(entry);
- message.displayName = await authorManager.getAuthorName(message.authorId);
- return message;
+ warnDeprecated('Pad.getChatMessage() is deprecated');
+ return await chat.getChatMessage(this, entryNum);
}
/**
+ * @deprecated
* @param {number} start - ID of the first desired chat message.
* @param {number} end - ID of the last desired chat message.
* @returns {ChatMessage[]} Any existing messages with IDs between `start` (inclusive) and `end`
@@ -327,19 +324,8 @@ class Pad {
* interval as is typical in code.
*/
async getChatMessages(start, end) {
- const entries =
- await Promise.all(Stream.range(start, end + 1).map(this.getChatMessage.bind(this)));
-
- // sort out broken chat entries
- // it looks like in happened in the past that the chat head was
- // incremented, but the chat message wasn't added
- return entries.filter((entry) => {
- const pass = (entry != null);
- if (!pass) {
- console.warn(`WARNING: Found broken chat entry in pad ${this.id}`);
- }
- return pass;
- });
+ warnDeprecated('Pad.getChatMessages() is deprecated');
+ return await chat.getChatMessages(this, start, end);
}
async init(text, authorId = '') {
@@ -386,7 +372,6 @@ class Pad {
const promises = (function* () {
yield copyRecord('');
yield* Stream.range(0, this.head + 1).map((i) => copyRecord(`:revs:${i}`));
- yield* Stream.range(0, this.chatHead + 1).map((i) => copyRecord(`:chat:${i}`));
yield this.copyAuthorInfoToDestinationPad(destinationID);
if (destGroupID) yield db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1);
}).call(this);
@@ -545,11 +530,6 @@ class Pad {
}));
p.push(db.remove(`pad2readonly:${padID}`));
- // delete all chat messages
- p.push(promises.timesLimit(this.chatHead + 1, 500, async (i) => {
- await this.db.remove(`pad:${this.id}:chat:${i}`, null);
- }));
-
// delete all revisions
p.push(promises.timesLimit(this.head + 1, 500, async (i) => {
await this.db.remove(`pad:${this.id}:revs:${i}`, null);
@@ -703,23 +683,6 @@ class Pad {
assert.deepEqual(this.atext, atext);
assert.deepEqual(this.getAllAuthors().sort(), [...authorIds].sort());
- assert(this.chatHead != null);
- assert(Number.isInteger(this.chatHead));
- assert(this.chatHead >= -1);
- const chats = Stream.range(0, this.chatHead + 1)
- .map(async (c) => {
- try {
- const msg = await this.getChatMessage(c);
- assert(msg != null);
- assert(msg instanceof ChatMessage);
- } catch (err) {
- err.message = `(pad ${this.id} chat message ${c}) ${err.message}`;
- throw err;
- }
- })
- .batch(100).buffer(99);
- for (const p of chats) await p;
-
await hooks.aCallAll('padCheck', {pad: this});
}
}
diff --git a/src/node/handler/PadMessageHandler.js b/src/node/handler/PadMessageHandler.js
index 9a1885b73..35965825a 100644
--- a/src/node/handler/PadMessageHandler.js
+++ b/src/node/handler/PadMessageHandler.js
@@ -40,6 +40,7 @@ const assert = require('assert').strict;
const {RateLimiterMemory} = require('rate-limiter-flexible');
const webaccess = require('../hooks/express/webaccess');
+let chat = null;
let rateLimiter;
let socketio = null;
@@ -54,6 +55,8 @@ const addContextToError = (err, pfx) => {
return err;
};
+exports.registerLegacyChatHandlers = (handlers) => chat = handlers;
+
exports.socketio = () => {
// The rate limiter is created in this hook so that restarting the server resets the limiter. The
// settings.commitRateLimiting object is passed directly to the rate limiter so that the limits
@@ -335,8 +338,6 @@ exports.handleMessage = async (socket, message) => {
await padChannels.enqueue(thisSession.padId, {socket, message});
break;
case 'USERINFO_UPDATE': await handleUserInfoUpdate(socket, message); break;
- case 'CHAT_MESSAGE': await handleChatMessage(socket, message); break;
- case 'GET_CHAT_MESSAGES': await handleGetChatMessages(socket, message); break;
case 'SAVE_REVISION': await handleSaveRevisionMessage(socket, message); break;
case 'CLIENT_MESSAGE': {
const {type} = message.data.payload;
@@ -413,23 +414,10 @@ exports.handleCustomMessage = (padID, msgString) => {
socketio.sockets.in(padID).json.send(msg);
};
-/**
- * Handles a Chat Message
- * @param socket the socket.io Socket object for the client
- * @param message the message from the client
- */
-const handleChatMessage = async (socket, message) => {
- const chatMessage = ChatMessage.fromObject(message.data.message);
- const {padId, author: authorId} = sessioninfos[socket.id];
- // Don't trust the user-supplied values.
- chatMessage.time = Date.now();
- chatMessage.authorId = authorId;
- await exports.sendChatMessageToPadClients(chatMessage, padId);
-};
-
/**
* Adds a new chat message to a pad and sends it to connected clients.
*
+ * @deprecated Use chat.sendChatMessageToPadClients() instead.
* @param {(ChatMessage|number)} mt - Either a chat message object (recommended) or the timestamp of
* the chat message in ms since epoch (deprecated).
* @param {string} puId - If `mt` is a chat message object, this is the destination pad ID.
@@ -439,45 +427,11 @@ const handleChatMessage = async (socket, message) => {
* object as the first argument and the destination pad ID as the second argument instead.
*/
exports.sendChatMessageToPadClients = async (mt, puId, text = null, padId = null) => {
+ padutils.warnDeprecated('PadMessageHandler.sendChatMessageToPadClients() is deprecated; ' +
+ 'use chat.sendChatMessageToPadClients() instead');
const message = mt instanceof ChatMessage ? mt : new ChatMessage(text, puId, mt);
padId = mt instanceof ChatMessage ? puId : padId;
- const pad = await padManager.getPad(padId, null, message.authorId);
- await hooks.aCallAll('chatNewMessage', {message, pad, padId});
- // pad.appendChatMessage() ignores the displayName property so we don't need to wait for
- // authorManager.getAuthorName() to resolve before saving the message to the database.
- const promise = pad.appendChatMessage(message);
- message.displayName = await authorManager.getAuthorName(message.authorId);
- socketio.sockets.in(padId).json.send({
- type: 'COLLABROOM',
- data: {type: 'CHAT_MESSAGE', message},
- });
- await promise;
-};
-
-/**
- * Handles the clients request for more chat-messages
- * @param socket the socket.io Socket object for the client
- * @param message the message from the client
- */
-const handleGetChatMessages = async (socket, {data: {start, end}}) => {
- if (!Number.isInteger(start)) throw new Error(`missing or invalid start: ${start}`);
- if (!Number.isInteger(end)) throw new Error(`missing or invalid end: ${end}`);
- const count = end - start;
- if (count < 0 || count > 100) throw new Error(`invalid number of messages: ${count}`);
- const {padId, author: authorId} = sessioninfos[socket.id];
- const pad = await padManager.getPad(padId, null, authorId);
-
- const chatMessages = await pad.getChatMessages(start, end);
- const infoMsg = {
- type: 'COLLABROOM',
- data: {
- type: 'CHAT_MESSAGES',
- messages: chatMessages,
- },
- };
-
- // send the messages back to the client
- socket.json.send(infoMsg);
+ await chat.sendChatMessageToPadClients(message, padId);
};
/**
@@ -949,9 +903,6 @@ const handleClientReady = async (socket, message) => {
padShortcutEnabled: settings.padShortcutEnabled,
initialTitle: `Pad: ${sessionInfo.auth.padID}`,
opts: {},
- // tell the client the number of the latest chat-message, which will be
- // used to request the latest 100 chat-messages later (GET_CHAT_MESSAGES)
- chatHead: pad.chatHead,
numConnectedUsers: roomSockets.length,
readOnlyId: sessionInfo.readOnlyPadId,
readonly: sessionInfo.readonly,
diff --git a/src/node/hooks/express/static.js b/src/node/hooks/express/static.js
index 26c18995a..54e19b867 100644
--- a/src/node/hooks/express/static.js
+++ b/src/node/hooks/express/static.js
@@ -1,15 +1,115 @@
'use strict';
-const fs = require('fs').promises;
const minify = require('../../utils/Minify');
-const path = require('path');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const settings = require('../../utils/Settings');
const CachingMiddleware = require('../../utils/caching_middleware');
const Yajsml = require('etherpad-yajsml');
-// Rewrite tar to include modules with no extensions and proper rooted paths.
-const getTar = async () => {
+const tar = (() => {
+ const associations = {
+ 'pad.js': [
+ 'pad.js',
+ 'pad_utils.js',
+ '$js-cookie/dist/js.cookie.js',
+ 'security.js',
+ '$security.js',
+ 'vendors/browser.js',
+ 'pad_cookie.js',
+ 'pad_editor.js',
+ 'pad_editbar.js',
+ 'vendors/nice-select.js',
+ 'pad_modals.js',
+ 'pad_automatic_reconnect.js',
+ 'ace.js',
+ 'collab_client.js',
+ 'cssmanager.js',
+ 'pad_userlist.js',
+ 'pad_impexp.js',
+ 'pad_savedrevs.js',
+ 'pad_connectionstatus.js',
+ ...settings.integratedChat ? [
+ 'ChatMessage.js',
+ 'chat.js',
+ '$tinycon/tinycon.js',
+ ] : [],
+ 'vendors/gritter.js',
+ '$js-cookie/dist/js.cookie.js',
+ 'vendors/farbtastic.js',
+ 'skin_variants.js',
+ 'socketio.js',
+ 'colorutils.js',
+ ],
+ 'timeslider.js': [
+ 'timeslider.js',
+ 'colorutils.js',
+ 'draggable.js',
+ 'pad_utils.js',
+ '$js-cookie/dist/js.cookie.js',
+ 'vendors/browser.js',
+ 'pad_cookie.js',
+ 'pad_editor.js',
+ 'pad_editbar.js',
+ 'vendors/nice-select.js',
+ 'pad_modals.js',
+ 'pad_automatic_reconnect.js',
+ 'pad_savedrevs.js',
+ 'pad_impexp.js',
+ 'AttributePool.js',
+ 'Changeset.js',
+ 'domline.js',
+ 'linestylefilter.js',
+ 'cssmanager.js',
+ 'broadcast.js',
+ 'broadcast_slider.js',
+ 'broadcast_revisions.js',
+ 'socketio.js',
+ 'AttributeManager.js',
+ 'AttributeMap.js',
+ 'attributes.js',
+ 'ChangesetUtils.js',
+ ],
+ 'ace2_inner.js': [
+ 'ace2_inner.js',
+ 'vendors/browser.js',
+ 'AttributePool.js',
+ 'Changeset.js',
+ 'ChangesetUtils.js',
+ 'skiplist.js',
+ 'colorutils.js',
+ 'undomodule.js',
+ '$unorm/lib/unorm.js',
+ 'contentcollector.js',
+ 'changesettracker.js',
+ 'linestylefilter.js',
+ 'domline.js',
+ 'AttributeManager.js',
+ 'AttributeMap.js',
+ 'attributes.js',
+ 'scroll.js',
+ 'caretPosition.js',
+ 'pad_utils.js',
+ '$js-cookie/dist/js.cookie.js',
+ 'security.js',
+ '$security.js',
+ ],
+ 'ace2_common.js': [
+ 'ace2_common.js',
+ 'vendors/browser.js',
+ 'vendors/jquery.js',
+ 'rjquery.js',
+ '$async.js',
+ 'underscore.js',
+ '$underscore.js',
+ '$underscore/underscore.js',
+ 'security.js',
+ '$security.js',
+ 'pluginfw/client_plugins.js',
+ 'pluginfw/plugin_defs.js',
+ 'pluginfw/shared.js',
+ 'pluginfw/hooks.js',
+ ],
+ };
const prefixLocalLibraryPath = (path) => {
if (path.charAt(0) === '$') {
return path.slice(1);
@@ -17,16 +117,15 @@ const getTar = async () => {
return `ep_etherpad-lite/static/js/${path}`;
}
};
- const tarJson = await fs.readFile(path.join(settings.root, 'src/node/utils/tar.json'), 'utf8');
const tar = {};
- for (const [key, relativeFiles] of Object.entries(JSON.parse(tarJson))) {
+ for (const [key, relativeFiles] of Object.entries(associations)) {
const files = relativeFiles.map(prefixLocalLibraryPath);
tar[prefixLocalLibraryPath(key)] = files
.concat(files.map((p) => p.replace(/\.js$/, '')))
.concat(files.map((p) => `${p.replace(/\.js$/, '')}/index.js`));
}
return tar;
-};
+})();
exports.expressPreSession = async (hookName, {app}) => {
// Cache both minified and static.
@@ -49,7 +148,7 @@ exports.expressPreSession = async (hookName, {app}) => {
});
const StaticAssociator = Yajsml.associators.StaticAssociator;
- const associations = Yajsml.associators.associationsForSimpleMapping(await getTar());
+ const associations = Yajsml.associators.associationsForSimpleMapping(tar);
const associator = new StaticAssociator(associations);
jsServer.setAssociator(associator);
@@ -59,10 +158,22 @@ exports.expressPreSession = async (hookName, {app}) => {
// not very static, but served here so that client can do
// require("pluginfw/static/js/plugin-definitions.js");
app.get('/pluginfw/plugin-definitions.json', (req, res, next) => {
- const clientParts = plugins.parts.filter((part) => part.client_hooks != null);
+ // No need to tell clients about server-side hooks.
+ const stripServerSideHooks = (parts) => parts.reduce((parts, part) => {
+ if (part.client_hooks != null) {
+ if (part.hooks) part = {...part, hooks: undefined};
+ parts.push(part);
+ }
+ return parts;
+ }, []);
+ const clientParts = stripServerSideHooks(plugins.parts);
const clientPlugins = {};
for (const name of new Set(clientParts.map((part) => part.plugin))) {
- clientPlugins[name] = {...plugins.plugins[name]};
+ const plugin = plugins.plugins[name];
+ clientPlugins[name] = {
+ ...plugin,
+ parts: stripServerSideHooks(plugin.parts),
+ };
delete clientPlugins[name].package;
}
res.setHeader('Content-Type', 'application/json; charset=utf-8');
diff --git a/src/node/hooks/express/tests.js b/src/node/hooks/express/tests.js
index 66b47d2af..7cade36d4 100644
--- a/src/node/hooks/express/tests.js
+++ b/src/node/hooks/express/tests.js
@@ -38,8 +38,10 @@ exports.expressPreSession = async (hookName, {app}) => {
if (!pluginPath.endsWith(path.sep)) pluginPath += path.sep;
const specDir = `${plugin === 'ep_etherpad-lite' ? '' : 'static/'}tests/frontend/specs`;
for (const spec of await findSpecs(path.join(pluginPath, specDir))) {
- if (plugin === 'ep_etherpad-lite' && !settings.enableAdminUITests &&
- spec.startsWith('admin')) continue;
+ if (plugin === 'ep_etherpad-lite') {
+ if (!settings.enableAdminUITests && spec.startsWith('admin')) continue;
+ if (!settings.integratedChat && spec.startsWith('chat')) continue;
+ }
modules.push(`${plugin}/${specDir}/${spec.replace(/\.js$/, '')}`);
}
}));
diff --git a/src/node/server.js b/src/node/server.js
index ae6cacb44..15c802c34 100755
--- a/src/node/server.js
+++ b/src/node/server.js
@@ -48,7 +48,6 @@ const UpdateCheck = require('./utils/UpdateCheck');
const db = require('./db/DB');
const express = require('./hooks/express');
const hooks = require('../static/js/pluginfw/hooks');
-const pluginDefs = require('../static/js/pluginfw/plugin_defs');
const plugins = require('../static/js/pluginfw/plugins');
const {Gate} = require('./utils/promises');
const stats = require('./stats');
@@ -134,13 +133,6 @@ exports.start = async () => {
await db.init();
await plugins.update();
- const installedPlugins = Object.values(pluginDefs.plugins)
- .filter((plugin) => plugin.package.name !== 'ep_etherpad-lite')
- .map((plugin) => `${plugin.package.name}@${plugin.package.version}`)
- .join(', ');
- logger.info(`Installed plugins: ${installedPlugins}`);
- logger.debug(`Installed parts:\n${plugins.formatParts()}`);
- logger.debug(`Installed server-side hooks:\n${plugins.formatHooks('hooks', false)}`);
await hooks.aCallAll('loadSettings', {settings});
await hooks.aCallAll('createServer');
} catch (err) {
diff --git a/src/node/utils/ExportEtherpad.js b/src/node/utils/ExportEtherpad.js
index e20739ad3..dfb486e2e 100644
--- a/src/node/utils/ExportEtherpad.js
+++ b/src/node/utils/ExportEtherpad.js
@@ -50,7 +50,6 @@ exports.getPadRaw = async (padId, readOnlyId) => {
})()];
}
for (let i = 0; i <= pad.head; ++i) yield [`${dstPfx}:revs:${i}`, pad.getRevision(i)];
- for (let i = 0; i <= pad.chatHead; ++i) yield [`${dstPfx}:chat:${i}`, pad.getChatMessage(i)];
for (const gen of pluginRecords) yield* gen;
})();
const data = {[dstPfx]: pad};
diff --git a/src/node/utils/ImportEtherpad.js b/src/node/utils/ImportEtherpad.js
index da7e750ff..d27af2a26 100644
--- a/src/node/utils/ImportEtherpad.js
+++ b/src/node/utils/ImportEtherpad.js
@@ -74,7 +74,9 @@ exports.setPadRaw = async (padId, r, authorId = '') => {
return;
}
value.padIDs = {[padId]: 1};
- } else if (padKeyPrefixes.includes(prefix)) {
+ } else if (padKeyPrefixes.includes(prefix) &&
+ // Chat message handling was moved to the importEtherpad hook.
+ (keyParts[0] !== 'pad' || keyParts[2] !== 'chat')) {
checkOriginalPadId(id);
if (prefix === 'pad' && keyParts.length === 2) {
const pool = new AttributePool().fromJsonable(value.pool);
diff --git a/src/node/utils/Minify.js b/src/node/utils/Minify.js
index 2e8a2d960..ae8241411 100644
--- a/src/node/utils/Minify.js
+++ b/src/node/utils/Minify.js
@@ -42,7 +42,7 @@ const LIBRARY_WHITELIST = [
'js-cookie',
'security',
'split-grid',
- 'tinycon',
+ ...settings.integratedChat ? ['tinycon'] : [],
'underscore',
'unorm',
];
diff --git a/src/node/utils/Settings.js b/src/node/utils/Settings.js
index 51f48237a..587a300f7 100644
--- a/src/node/utils/Settings.js
+++ b/src/node/utils/Settings.js
@@ -156,6 +156,12 @@ exports.defaultPadText = [
'Etherpad on Github: https://github.com/ether/etherpad-lite',
].join('\n');
+/**
+ * Whether to enable the built-in chat feature. Set this to false if you prefer to use a plugin to
+ * provide chat functionality or simply do not want the feature.
+ */
+exports.integratedChat = true;
+
/**
* The default Pad Settings for a user (Can be overridden by changing the setting
*/
diff --git a/src/node/utils/tar.json b/src/node/utils/tar.json
deleted file mode 100644
index 08ae93f6b..000000000
--- a/src/node/utils/tar.json
+++ /dev/null
@@ -1,101 +0,0 @@
-{
- "pad.js": [
- "pad.js"
- , "pad_utils.js"
- , "$js-cookie/dist/js.cookie.js"
- , "security.js"
- , "$security.js"
- , "vendors/browser.js"
- , "pad_cookie.js"
- , "pad_editor.js"
- , "pad_editbar.js"
- , "vendors/nice-select.js"
- , "pad_modals.js"
- , "pad_automatic_reconnect.js"
- , "ace.js"
- , "collab_client.js"
- , "cssmanager.js"
- , "pad_userlist.js"
- , "pad_impexp.js"
- , "pad_savedrevs.js"
- , "pad_connectionstatus.js"
- , "ChatMessage.js"
- , "chat.js"
- , "vendors/gritter.js"
- , "$js-cookie/dist/js.cookie.js"
- , "$tinycon/tinycon.js"
- , "vendors/farbtastic.js"
- , "skin_variants.js"
- , "socketio.js"
- , "colorutils.js"
- ]
-, "timeslider.js": [
- "timeslider.js"
- , "colorutils.js"
- , "draggable.js"
- , "pad_utils.js"
- , "$js-cookie/dist/js.cookie.js"
- , "vendors/browser.js"
- , "pad_cookie.js"
- , "pad_editor.js"
- , "pad_editbar.js"
- , "vendors/nice-select.js"
- , "pad_modals.js"
- , "pad_automatic_reconnect.js"
- , "pad_savedrevs.js"
- , "pad_impexp.js"
- , "AttributePool.js"
- , "Changeset.js"
- , "domline.js"
- , "linestylefilter.js"
- , "cssmanager.js"
- , "broadcast.js"
- , "broadcast_slider.js"
- , "broadcast_revisions.js"
- , "socketio.js"
- , "AttributeManager.js"
- , "AttributeMap.js"
- , "attributes.js"
- , "ChangesetUtils.js"
- ]
-, "ace2_inner.js": [
- "ace2_inner.js"
- , "vendors/browser.js"
- , "AttributePool.js"
- , "Changeset.js"
- , "ChangesetUtils.js"
- , "skiplist.js"
- , "colorutils.js"
- , "undomodule.js"
- , "$unorm/lib/unorm.js"
- , "contentcollector.js"
- , "changesettracker.js"
- , "linestylefilter.js"
- , "domline.js"
- , "AttributeManager.js"
- , "AttributeMap.js"
- , "attributes.js"
- , "scroll.js"
- , "caretPosition.js"
- , "pad_utils.js"
- , "$js-cookie/dist/js.cookie.js"
- , "security.js"
- , "$security.js"
- ]
-, "ace2_common.js": [
- "ace2_common.js"
- , "vendors/browser.js"
- , "vendors/jquery.js"
- , "rjquery.js"
- , "$async.js"
- , "underscore.js"
- , "$underscore.js"
- , "$underscore/underscore.js"
- , "security.js"
- , "$security.js"
- , "pluginfw/client_plugins.js"
- , "pluginfw/plugin_defs.js"
- , "pluginfw/shared.js"
- , "pluginfw/hooks.js"
- ]
-}
diff --git a/src/static/css/pad/layout.css b/src/static/css/pad/layout.css
index 7f77ca58d..4bdb5061d 100644
--- a/src/static/css/pad/layout.css
+++ b/src/static/css/pad/layout.css
@@ -46,7 +46,7 @@ body {
max-width: 40%;
flex-shrink: 0;
}
-#editorcontainerbox .sticky-container:not(.stikyUsers):not(.stickyChat) {
+#editorcontainerbox .sticky-container:not(.stickyUsers):not(.stickyChat) {
width: 0; /* hide when the container is empty */
}
diff --git a/src/static/js/ace2_inner.js b/src/static/js/ace2_inner.js
index 785473df7..6874c85fd 100644
--- a/src/static/js/ace2_inner.js
+++ b/src/static/js/ace2_inner.js
@@ -2585,15 +2585,6 @@ function Ace2Inner(editorInfo, cssManagers) {
firstEditbarElement.focus();
evt.preventDefault();
}
- if (!specialHandled && type === 'keydown' &&
- altKey && keyCode === 67 &&
- padShortcutEnabled.altC) {
- // Alt c focuses on the Chat window
- $(this).blur();
- parent.parent.chat.show();
- parent.parent.$('#chatinput').focus();
- evt.preventDefault();
- }
if (!specialHandled && type === 'keydown' &&
evt.ctrlKey && shiftKey && keyCode === 50 &&
padShortcutEnabled.cmdShift2) {
diff --git a/src/static/js/chat.js b/src/static/js/chat.js
index 63c17c153..256326fd3 100755
--- a/src/static/js/chat.js
+++ b/src/static/js/chat.js
@@ -25,7 +25,7 @@ const padeditor = require('./pad_editor').padeditor;
// Removes diacritics and lower-cases letters. https://stackoverflow.com/a/37511463
const normalize = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
-exports.chat = (() => {
+const chat = (() => {
let isStuck = false;
let userAndChat = false;
let chatMentions = 0;
@@ -79,17 +79,35 @@ exports.chat = (() => {
.toggleClass('chatAndUsers popup-show stickyUsers', userAndChat);
$('#chatbox').toggleClass('chatAndUsersChat', userAndChat);
},
- hide() {
+ reduce() {
// decide on hide logic based on chat window being maximized or not
if ($('#options-stickychat').prop('checked')) {
this.stickToScreen();
$('#options-stickychat').prop('checked', false);
} else {
$('#chatcounter').text('0');
+ // It usually is not necessary to call .show() because .hide() is only normally called when
+ // the pad is loaded and showChat=false. When showChat=false, there are no chat UI elements
+ // so there's nothing to click on to get the chatbox to display. However, there are other
+ // ways to get the chatbox to display:
+ // * A plugin might call `chat.show()`.
+ // * The user can hit Alt-C (assuming the shortcut is enabled).
+ // * The user can run `chat.show()` in the developer console.
+ // In all cases, reducing the shown chatbox should cause it to minimize to an icon, not
+ // vanish completely.
+ $('#chaticon').show();
$('#chaticon').addClass('visible');
$('#chatbox').removeClass('visible');
}
},
+ minimize() {
+ if ($('#options-stickychat').prop('checked')) this.reduce();
+ this.reduce();
+ },
+ hide() {
+ this.minimize();
+ $('#chaticon').hide();
+ },
scrollDown(force) {
if ($('#chatbox').hasClass('visible')) {
if (force || !this.lastMessage || !this.lastMessage.position() ||
@@ -218,6 +236,11 @@ exports.chat = (() => {
},
init(pad) {
this._pad = pad;
+ $('#options-stickychat').on('click', () => this.stickToScreen());
+ $('#options-chatandusers').on('click', () => this.chatAndUsers());
+ $('#chaticon').on('click', () => { this.show(); return false; });
+ $('#titlecross').on('click', () => { this.reduce(); return false; });
+ $('#titlesticky').on('click', () => { this.stickToScreen(true); return false; });
$('#chatinput').on('keydown', (evt) => {
// If the event is Alt C or Escape & we're already in the chat menu
// Send the users focus back to the pad
@@ -235,17 +258,6 @@ exports.chat = (() => {
Tinycon.setBubble(0);
});
- const self = this;
- $('body:not(#chatinput)').on('keypress', function (evt) {
- if (evt.altKey && evt.which === 67) {
- // Alt c focuses on the Chat window
- $(this).blur();
- self.show();
- $('#chatinput').focus();
- evt.preventDefault();
- }
- });
-
$('#chatinput').keypress((evt) => {
// if the user typed enter, fire the send
if (evt.key === 'Enter' && !evt.shiftKey) {
@@ -269,6 +281,110 @@ exports.chat = (() => {
pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end});
this.historyPointer = start;
});
+
+ const {searchParams} = new URL(window.location.href);
+ const {showChat = true, alwaysShowChat = false, chatAndUsers = false} = clientVars.padOptions;
+ const settings = this._pad.settings;
+ settings.hideChat = showChat.toString() === 'false';
+ if (settings.hideChat) this.hide();
+ if (alwaysShowChat.toString() === 'true' && !settings.hideChat) this.stickToScreen();
+ if (chatAndUsers.toString() === 'true') this.chatAndUsers();
+ settings.hideChat = searchParams.get('showChat') === 'false';
+ if (settings.hideChat) this.hide();
+ if (searchParams.get('alwaysShowChat') === 'true' && !settings.hideChat) this.stickToScreen();
+ if (searchParams.get('chatAndUsers') === 'true') this.chatAndUsers();
+
+ const chatVisCookie = !!padcookie.getPref('chatAlwaysVisible');
+ if (chatVisCookie) this.stickToScreen(true);
+ $('#options-stickychat').prop('checked', chatVisCookie);
+
+ const chatAUVisCookie = !!padcookie.getPref('chatAndUsersVisible');
+ if (chatAUVisCookie) this.chatAndUsers(true);
+ $('#options-chatandusers').prop('checked', chatAUVisCookie);
},
};
})();
+
+Object.defineProperty(exports, 'chat', {
+ get: () => {
+ padutils.warnDeprecated(
+ 'chat.chat is deprecated and will be removed in a future version of Etherpad');
+ return chat;
+ },
+});
+
+exports.aceKeyEvent = (hookName, {evt}) => {
+ const {altC} = window.clientVars.padShortcutEnabled;
+ if (evt.type !== 'keydown' || !evt.altKey || evt.keyCode !== 67 || !altC) return;
+ evt.target.blur();
+ chat.show();
+ chat.focus();
+ evt.preventDefault();
+ return true;
+};
+
+exports.handleClientMessage_CHAT_MESSAGE = (hookName, {msg}) => {
+ chat.addMessage(msg.message, true, false);
+};
+
+exports.handleClientMessage_CHAT_MESSAGES = (hookName, {msg}) => {
+ for (let i = msg.messages.length - 1; i >= 0; i--) {
+ chat.addMessage(msg.messages[i], true, true);
+ }
+ if (!chat.gotInitalMessages) {
+ chat.scrollDown();
+ chat.gotInitalMessages = true;
+ chat.historyPointer = clientVars.chatHead - msg.messages.length;
+ }
+
+ // messages are loaded, so hide the loading-ball
+ $('#chatloadmessagesball').css('display', 'none');
+
+ // there are less than 100 messages or we reached the top
+ if (chat.historyPointer <= 0) {
+ $('#chatloadmessagesbutton').css('display', 'none');
+ } else {
+ // there are still more messages, re-show the load-button
+ $('#chatloadmessagesbutton').css('display', 'block');
+ }
+};
+
+exports.postAceInit = async (hookName, {clientVars, pad}) => {
+ chat.init(pad);
+
+ if (padcookie.getPref('chatAlwaysVisible')) {
+ chat.stickToScreen(true);
+ $('#options-stickychat').prop('checked', true);
+ }
+ if (padcookie.getPref('chatAndUsers')) {
+ chat.chatAndUsers(true);
+ $('#options-chatandusers').prop('checked', true);
+ }
+
+ // Prevent sticky chat or chat and users to be checked for mobiles
+ const checkChatAndUsersVisibility = (x) => {
+ if (!x.matches) return;
+ $('#options-chatandusers:checked').click();
+ $('#options-stickychat:checked').click();
+ };
+ const mobileMatch = window.matchMedia('(max-width: 800px)');
+ mobileMatch.addListener(checkChatAndUsersVisibility);
+ setTimeout(() => { checkChatAndUsersVisibility(mobileMatch); }, 0);
+
+ if (clientVars.chatHead !== -1) {
+ const chatHead = clientVars.chatHead;
+ const start = Math.max(chatHead - 100, 0);
+ pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end: chatHead});
+ } else {
+ $('#chatloadmessagesbutton').css('display', 'none');
+ }
+
+ if (clientVars.readonly) {
+ chat.hide();
+ $('#chatinput').attr('disabled', true);
+ $('#options-chatandusers').parent().hide();
+ $('#options-stickychat').parent().hide();
+ } else if (!pad.settings.hideChat) {
+ $('#chaticon').show();
+ }
+};
diff --git a/src/static/js/collab_client.js b/src/static/js/collab_client.js
index 74bc66f9f..fe6f5319e 100644
--- a/src/static/js/collab_client.js
+++ b/src/static/js/collab_client.js
@@ -22,7 +22,6 @@
* limitations under the License.
*/
-const chat = require('./chat').chat;
const hooks = require('./pluginfw/hooks');
const browser = require('./vendors/browser');
@@ -266,28 +265,6 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
}
} else if (msg.type === 'CLIENT_MESSAGE') {
callbacks.onClientMessage(msg.payload);
- } else if (msg.type === 'CHAT_MESSAGE') {
- chat.addMessage(msg.message, true, false);
- } else if (msg.type === 'CHAT_MESSAGES') {
- for (let i = msg.messages.length - 1; i >= 0; i--) {
- chat.addMessage(msg.messages[i], true, true);
- }
- if (!chat.gotInitalMessages) {
- chat.scrollDown();
- chat.gotInitalMessages = true;
- chat.historyPointer = clientVars.chatHead - msg.messages.length;
- }
-
- // messages are loaded, so hide the loading-ball
- $('#chatloadmessagesball').css('display', 'none');
-
- // there are less than 100 messages or we reached the top
- if (chat.historyPointer <= 0) {
- $('#chatloadmessagesbutton').css('display', 'none');
- } else {
- // there are still more messages, re-show the load-button
- $('#chatloadmessagesbutton').css('display', 'block');
- }
}
// HACKISH: User messages do not have "payload" but "userInfo", so that all
@@ -300,7 +277,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
// Similar for NEW_CHANGES
if (msg.type === 'NEW_CHANGES') msg.payload = msg;
- hooks.callAll(`handleClientMessage_${msg.type}`, {payload: msg.payload});
+ hooks.callAll(`handleClientMessage_${msg.type}`, {msg, payload: msg.payload});
};
const updateUserInfo = (userInfo) => {
diff --git a/src/static/js/pad.js b/src/static/js/pad.js
index c37920ead..c99e4fdef 100644
--- a/src/static/js/pad.js
+++ b/src/static/js/pad.js
@@ -31,7 +31,6 @@ require('./vendors/farbtastic');
require('./vendors/gritter');
const Cookies = require('./pad_utils').Cookies;
-const chat = require('./chat').chat;
const getCollabClient = require('./collab_client').getCollabClient;
const padconnectionstatus = require('./pad_connectionstatus').padconnectionstatus;
const padcookie = require('./pad_cookie').padcookie;
@@ -70,17 +69,6 @@ const getParameters = [
$('#editbar').css('display', 'flex');
},
},
- {
- name: 'showChat',
- checkVal: null,
- callback: (val) => {
- if (val === 'false') {
- settings.hideChat = true;
- chat.hide();
- $('#chaticon').hide();
- }
- },
- },
{
name: 'showLineNumbers',
checkVal: 'false',
@@ -118,20 +106,6 @@ const getParameters = [
settings.rtlIsTrue = true;
},
},
- {
- name: 'alwaysShowChat',
- checkVal: 'true',
- callback: (val) => {
- if (!settings.hideChat) chat.stickToScreen();
- },
- },
- {
- name: 'chatAndUsers',
- checkVal: 'true',
- callback: (val) => {
- chat.chatAndUsers();
- },
- },
{
name: 'lang',
checkVal: null,
@@ -392,8 +366,6 @@ const pad = {
},
_afterHandshake() {
pad.clientTimeOffset = Date.now() - clientVars.serverTimestamp;
- // initialize the chat
- chat.init(this);
getParams();
padcookie.init(); // initialize the cookies
@@ -412,16 +384,6 @@ const pad = {
setTimeout(() => {
padeditor.ace.focus();
}, 0);
- // if we have a cookie for always showing chat then show it
- if (padcookie.getPref('chatAlwaysVisible')) {
- chat.stickToScreen(true); // stick it to the screen
- $('#options-stickychat').prop('checked', true); // set the checkbox to on
- }
- // if we have a cookie for always showing chat then show it
- if (padcookie.getPref('chatAndUsers')) {
- chat.chatAndUsers(true); // stick it to the screen
- $('#options-chatandusers').prop('checked', true); // set the checkbox to on
- }
if (padcookie.getPref('showAuthorshipColors') === false) {
pad.changeViewOption('showAuthorColors', false);
}
@@ -434,17 +396,6 @@ const pad = {
pad.changeViewOption('padFontFamily', padcookie.getPref('padFontFamily'));
$('#viewfontmenu').val(padcookie.getPref('padFontFamily')).niceSelect('update');
- // Prevent sticky chat or chat and users to be checked for mobiles
- const checkChatAndUsersVisibility = (x) => {
- if (x.matches) { // If media query matches
- $('#options-chatandusers:checked').click();
- $('#options-stickychat:checked').click();
- }
- };
- const mobileMatch = window.matchMedia('(max-width: 800px)');
- mobileMatch.addListener(checkChatAndUsersVisibility); // check if window resized
- setTimeout(() => { checkChatAndUsersVisibility(mobileMatch); }, 0); // check now after load
-
$('#editorcontainer').addClass('initialized');
hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad});
@@ -469,24 +420,7 @@ const pad = {
pad.collabClient.setOnChannelStateChange(pad.handleChannelStateChange);
pad.collabClient.setOnInternalAction(pad.handleCollabAction);
- // load initial chat-messages
- if (clientVars.chatHead !== -1) {
- const chatHead = clientVars.chatHead;
- const start = Math.max(chatHead - 100, 0);
- pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end: chatHead});
- } else {
- // there are no messages
- $('#chatloadmessagesbutton').css('display', 'none');
- }
-
- if (window.clientVars.readonly) {
- chat.hide();
- $('#myusernameedit').attr('disabled', true);
- $('#chatinput').attr('disabled', true);
- $('#chaticon').hide();
- $('#options-chatandusers').parent().hide();
- $('#options-stickychat').parent().hide();
- } else if (!settings.hideChat) { $('#chaticon').show(); }
+ if (window.clientVars.readonly) $('#myusernameedit').attr('disabled', true);
$('body').addClass(window.clientVars.readonly ? 'readonly' : 'readwrite');
@@ -650,31 +584,11 @@ const pad = {
}
},
handleIsFullyConnected: (isConnected, isInitialConnect) => {
- pad.determineChatVisibility(isConnected && !isInitialConnect);
- pad.determineChatAndUsersVisibility(isConnected && !isInitialConnect);
pad.determineAuthorshipColorsVisibility();
setTimeout(() => {
padeditbar.toggleDropDown('none');
}, 1000);
},
- determineChatVisibility: (asNowConnectedFeedback) => {
- const chatVisCookie = padcookie.getPref('chatAlwaysVisible');
- if (chatVisCookie) { // if the cookie is set for chat always visible
- chat.stickToScreen(true); // stick it to the screen
- $('#options-stickychat').prop('checked', true); // set the checkbox to on
- } else {
- $('#options-stickychat').prop('checked', false); // set the checkbox for off
- }
- },
- determineChatAndUsersVisibility: (asNowConnectedFeedback) => {
- const chatAUVisCookie = padcookie.getPref('chatAndUsersVisible');
- if (chatAUVisCookie) { // if the cookie is set for chat always visible
- chat.chatAndUsers(true); // stick it to the screen
- $('#options-chatandusers').prop('checked', true); // set the checkbox to on
- } else {
- $('#options-chatandusers').prop('checked', false); // set the checkbox for off
- }
- },
determineAuthorshipColorsVisibility: () => {
const authColCookie = padcookie.getPref('showAuthorshipColors');
if (authColCookie) {
diff --git a/src/static/js/pluginfw/plugins.js b/src/static/js/pluginfw/plugins.js
index ec3cfaa92..8daf0329b 100644
--- a/src/static/js/pluginfw/plugins.js
+++ b/src/static/js/pluginfw/plugins.js
@@ -5,6 +5,7 @@ const hooks = require('./hooks');
const log4js = require('log4js');
const path = require('path');
const runCmd = require('../../../node/utils/run_cmd');
+const settings = require('../../../node/utils/Settings');
const tsort = require('./tsort');
const pluginUtils = require('./shared');
const defs = require('./plugin_defs');
@@ -102,6 +103,13 @@ exports.update = async () => {
const logger = log4js.getLogger(`plugin:${p}`);
await hooks.aCallAll(`init_${p}`, {logger});
}));
+ const installedPlugins = Object.values(defs.plugins)
+ .filter((plugin) => plugin.package.name !== 'ep_etherpad-lite')
+ .map((plugin) => `${plugin.package.name}@${plugin.package.version}`)
+ .join(', ');
+ logger.info(`Installed plugins: ${installedPlugins}`);
+ logger.debug(`Installed parts:\n${exports.formatParts()}`);
+ logger.debug(`Installed server-side hooks:\n${exports.formatHooks('hooks', false)}`);
};
exports.getPackages = async () => {
@@ -129,6 +137,9 @@ const loadPlugin = async (packages, pluginName, plugins, parts) => {
const data = await fs.readFile(pluginPath);
try {
const plugin = JSON.parse(data);
+ if (pluginName === 'ep_etherpad-lite' && !settings.integratedChat) {
+ plugin.parts = plugin.parts.filter((part) => part.name !== 'chat');
+ }
plugin.package = packages[pluginName];
plugins[pluginName] = plugin;
for (const part of plugin.parts) {
diff --git a/src/templates/pad.html b/src/templates/pad.html
index bc3cec88e..497a05201 100644
--- a/src/templates/pad.html
+++ b/src/templates/pad.html
@@ -62,348 +62,321 @@
<% e.begin_block("afterEditbar"); %><% e.end_block(); %>
- <% e.begin_block("editorContainerBox"); %>
+ <% e.begin_block("editorContainerBox"); %>
-
-
-
+
+
+
-
+
-
- <% e.begin_block("permissionDenied"); %>
-
-
- You do not have permission to access this pad
-
-
- <% e.end_block(); %>
- <% e.begin_block("loading"); %>
-
-
- Loading...
+
+ <% e.begin_block("permissionDenied"); %>
+
+
+ You do not have permission to access this pad
+
+
+ <% e.end_block(); %>
+ <% e.begin_block("loading"); %>
+
+
+ Loading...
+
+ <% e.end_block(); %>
+
+
+
+ Sorry, you have to enable Javascript in order to use this.
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-