This commit is contained in:
Richard Hansen 2022-05-09 10:36:11 -04:00 committed by GitHub
commit 4e914c599c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1339 additions and 1083 deletions

View file

@ -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:

View file

@ -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

View file

@ -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.

View file

@ -81,10 +81,11 @@ 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` |
| `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) | |

View file

@ -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}",

View file

@ -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,

View file

@ -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": {

324
src/node/chat.js Normal file
View file

@ -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 += `
<p class="hide-for-mobile">
<input type="checkbox" id="options-stickychat">
<label for="options-stickychat" data-l10n-id="pad.settings.stickychat"></label>
</p>
<p class="hide-for-mobile">
<input type="checkbox" id="options-chatandusers">
<label for="options-chatandusers" data-l10n-id="pad.settings.chatandusers"></label>
</p>
`;
};
exports.eejsBlock_stickyContainer = (hookName, context) => {
if (!settings.integratedChat) return;
/* eslint-disable max-len */
context.content += `
<div id="chaticon" class="visible" title="Chat (Alt C)">
<span id="chatlabel" data-l10n-id="pad.chat"></span>
<span class="buttonicon buttonicon-chat"></span>
<span id="chatcounter">0</span>
</div>
<div id="chatbox">
<div class="chat-content">
<div id="titlebar">
<h1 id ="titlelabel" data-l10n-id="pad.chat"></h1>
<a id="titlecross" class="hide-reduce-btn">-&nbsp;</a>
<a id="titlesticky" class="stick-to-screen-btn" data-l10n-id="pad.chat.stick.title">&nbsp;&nbsp;</a>
</div>
<div id="chattext" class="thin-scrollbar" aria-live="polite" aria-relevant="additions removals text" role="log" aria-atomic="false">
<div alt="loading.." id="chatloadmessagesball" class="chatloadmessages loadingAnimation" align="top"></div>
<button id="chatloadmessagesbutton" class="chatloadmessages" data-l10n-id="pad.chat.loadmessages"></button>
</div>
<div id="chatinputbox">
<form>
<textarea id="chatinput" maxlength="999" data-l10n-id="pad.chat.writeMessage.placeholder"></textarea>
</form>
</div>
</div>
</div>
`;
/* 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,
});

View file

@ -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

View file

@ -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});
}
}

View file

@ -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,

View file

@ -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');

View file

@ -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$/, '')}`);
}
}));

View file

@ -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) {

View file

@ -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};

View file

@ -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);

View file

@ -42,7 +42,7 @@ const LIBRARY_WHITELIST = [
'js-cookie',
'security',
'split-grid',
'tinycon',
...settings.integratedChat ? ['tinycon'] : [],
'underscore',
'unorm',
];

View file

@ -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
*/

View file

@ -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"
]
}

View file

@ -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 */
}

View file

@ -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) {

View file

@ -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();
}
};

View file

@ -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) => {

View file

@ -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) {

View file

@ -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) {

View file

@ -112,23 +112,15 @@
</noscript>
</div>
<!-------------------------------------------->
<!-- SETTINGS POPUP (change font, language) -->
<!-------------------------------------------->
<!------------------------------------------------------------->
<!-- SETTINGS POPUP (change font, language, chat parameters) -->
<!------------------------------------------------------------->
<div id="settings" class="popup"><div class="popup-content">
<div id="settings" class="popup">
<div class="popup-content">
<h1 data-l10n-id="pad.settings.padSettings"></h1>
<% e.begin_block("mySettings"); %>
<h2 data-l10n-id="pad.settings.myView"></h2>
<p class="hide-for-mobile">
<input type="checkbox" id="options-stickychat" onClick="chat.stickToScreen();">
<label for="options-stickychat" data-l10n-id="pad.settings.stickychat"></label>
</p>
<p class="hide-for-mobile">
<input type="checkbox" id="options-chatandusers" onClick="chat.chatAndUsers();">
<label for="options-chatandusers" data-l10n-id="pad.settings.chatandusers"></label>
</p>
<p>
<input type="checkbox" id="options-colorscheck">
<label for="options-colorscheck" data-l10n-id="pad.settings.colorcheck"></label>
@ -171,14 +163,15 @@
<span data-l10n-id="pad.settings.poweredBy">Powered by</span>
<a href="https://etherpad.org">Etherpad</a>
<% if (settings.exposeVersion) { %>(commit <%=settings.getGitCommit()%>)<% } %>
</div></div>
</div>
</div>
<!------------------------->
<!-- IMPORT EXPORT POPUP -->
<!------------------------->
<div id="import_export" class="popup"><div class="popup-content">
<div id="import_export" class="popup">
<div class="popup-content">
<h1 data-l10n-id="pad.importExport.import_export"></h1>
<div class="acl-write">
<% e.begin_block("importColumn"); %>
@ -223,14 +216,15 @@
</a>
<% e.end_block(); %>
</div>
</div></div>
</div>
</div>
<!---------------------------------------------------->
<!-- CONNECTIVITY POPUP (when you get disconnected) -->
<!---------------------------------------------------->
<div id="connectivity" class="popup"><div class="popup-content">
<div id="connectivity" class="popup">
<div class="popup-content">
<% e.begin_block("modals"); %>
<div class="connected visible">
<h2 data-l10n-id="pad.modals.connected"></h2>
@ -306,14 +300,15 @@
<input type="hidden" class="missedChanges" name="missedChanges">
</form>
<% e.end_block(); %>
</div></div>
</div>
</div>
<!-------------------------------->
<!-- EMBED POPUP (Share, embed) -->
<!-------------------------------->
<div id="embed" class="popup"><div class="popup-content">
<div id="embed" class="popup">
<div class="popup-content">
<% e.begin_block("embedPopup"); %>
<h1 data-l10n-id="pad.share"></h1>
<div id="embedreadonly" class="acl-write">
@ -329,26 +324,31 @@
<input id="embedinput" type="text" value="" onclick="this.select()">
</div>
<% e.end_block(); %>
</div></div>
</div>
</div>
<div class="sticky-container">
<% e.begin_block("stickyContainer"); %>
<!---------------------------------------------------------------------->
<!-- USERS POPUP (set username, color, see other users names & color) -->
<!---------------------------------------------------------------------->
<div id="users" class="popup"><div class="popup-content">
<div id="users" class="popup">
<div class="popup-content">
<% e.begin_block("userlist"); %>
<div id="connectionstatus"></div>
<div id="myuser">
<div id="mycolorpicker" class="popup"><div class="popup-content">
<div id="mycolorpicker" class="popup">
<div class="popup-content">
<div id="colorpicker"></div>
<div class="btn-container">
<button id="mycolorpickersave" data-l10n-id="pad.colorpicker.save" class="btn btn-primary"></button>
<button id="mycolorpickercancel" data-l10n-id="pad.colorpicker.cancel" class="btn btn-default"></button>
<span id="mycolorpickerpreview" class="myswatchboxhoverable"></span>
</div>
</div></div>
</div>
</div>
<div id="myswatchbox"><div id="myswatch"></div></div>
<div id="myusernameform">
<input type="text" id="myusernameedit" disabled="disabled" data-l10n-id="pad.userlist.entername">
@ -361,44 +361,17 @@
</div>
<div id="userlistbuttonarea"></div>
<% e.end_block(); %>
</div></div>
<!----------------------------->
<!----------- CHAT ------------>
<!----------------------------->
<div id="chaticon" class="visible" onclick="chat.show();return false;" title="Chat (Alt C)">
<span id="chatlabel" data-l10n-id="pad.chat"></span>
<span class="buttonicon buttonicon-chat"></span>
<span id="chatcounter">0</span>
</div>
<div id="chatbox">
<div class="chat-content">
<div id="titlebar">
<h1 id ="titlelabel" data-l10n-id="pad.chat"></h1>
<a id="titlecross" class="hide-reduce-btn" onClick="chat.hide();return false;">-&nbsp;</a>
<a id="titlesticky" class="stick-to-screen-btn" onClick="chat.stickToScreen(true);return false;" data-l10n-id="pad.chat.stick.title">&nbsp;&nbsp;</a>
</div>
<div id="chattext" class="thin-scrollbar" aria-live="polite" aria-relevant="additions removals text" role="log" aria-atomic="false">
<div alt="loading.." id="chatloadmessagesball" class="chatloadmessages loadingAnimation" align="top"></div>
<button id="chatloadmessagesbutton" class="chatloadmessages" data-l10n-id="pad.chat.loadmessages"></button>
</div>
<div id="chatinputbox">
<form>
<textarea id="chatinput" maxlength="999" data-l10n-id="pad.chat.writeMessage.placeholder"></textarea>
</form>
</div>
</div>
</div>
<% e.end_block(); %><!-- end stickyContainer -->
</div>
<!------------------------------------------------------------------>
<!-- SKIN VARIANTS BUILDER (Customize rendering, only for admins) -->
<!------------------------------------------------------------------>
<% if (settings.skinName == 'colibris') { %>
<div id="skin-variants" class="popup"><div class="popup-content">
<div id="skin-variants" class="popup">
<div class="popup-content">
<h1>Skin Builder</h1>
<div class="dropdowns-container">
@ -425,7 +398,8 @@
<label>Result to copy in settings.json</label>
<input id="skin-variants-result" type="text" readonly class="disabled" />
</p>
</div></div>
</div>
</div>
<% } %>
<% e.end_block(); %>
@ -434,7 +408,6 @@
<% e.end_block(); %>
<!----------------------------->
<!-------- JAVASCRIPT --------->
<!----------------------------->
@ -494,9 +467,18 @@
/* TODO: These globals shouldn't exist. */
pad = require('ep_etherpad-lite/static/js/pad').pad;
chat = require('ep_etherpad-lite/static/js/chat').chat;
padeditbar = require('ep_etherpad-lite/static/js/pad_editbar').padeditbar;
padimpexp = require('ep_etherpad-lite/static/js/pad_impexp').padimpexp;
Object.defineProperty(window, 'chat', {
get: () => {
const {padutils: {warnDeprecated}} = require('ep_etherpad-lite/static/js/pad_utils');
warnDeprecated(
'window.chat is deprecated and will be removed in a future version of Etherpad');
return require('ep_etherpad-lite/static/js/chat').chat;
},
});
require('ep_etherpad-lite/static/js/skin_variants');
}());

View file

@ -1,6 +1,9 @@
'use strict';
const assert = require('assert').strict;
const common = require('../../common');
const plugins = require('../../../../static/js/pluginfw/plugins');
const settings = require('../../../../node/utils/Settings');
let agent;
const apiKey = common.apiKey;
@ -12,94 +15,107 @@ const timestamp = Date.now();
const endPoint = (point) => `/api/${apiVersion}/${point}?apikey=${apiKey}`;
describe(__filename, function () {
before(async function () { agent = await common.init(); });
const backups = {settings: {}};
describe('API Versioning', function () {
it('errors if can not connect', function (done) {
agent.get('/api/')
before(async function () {
backups.settings.integratedChat = settings.integratedChat;
settings.integratedChat = true;
await plugins.update();
agent = await common.init();
await agent.get('/api/')
.expect(200)
.expect((res) => {
assert(res.body.currentVersion);
apiVersion = res.body.currentVersion;
if (!res.body.currentVersion) throw new Error('No version set in API');
return;
})
.expect(200, done);
});
});
// BEGIN GROUP AND AUTHOR TESTS
// ///////////////////////////////////
// ///////////////////////////////////
/* Tests performed
-> createPad(padID)
-> createAuthor([name]) -- should return an authorID
-> appendChatMessage(padID, text, authorID, time)
-> getChatHead(padID)
-> getChatHistory(padID)
*/
describe('createPad', function () {
it('creates a new Pad', function (done) {
agent.get(`${endPoint('createPad')}&padID=${padID}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to create new Pad');
})
await agent.get(`${endPoint('createPad')}&padID=${padID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('createAuthor', function () {
it('Creates an author with a name set', function (done) {
agent.get(endPoint('createAuthor'))
.expect((res) => {
if (res.body.code !== 0 || !res.body.data.authorID) {
throw new Error('Unable to create author');
}
assert.equal(res.body.code, 0);
});
await agent.get(endPoint('createAuthor'))
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert(res.body.data.authorID);
authorID = res.body.data.authorID; // we will be this author for the rest of the tests
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('appendChatMessage', function () {
it('Adds a chat message to the pad', function (done) {
agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` +
after(async function () {
Object.assign(settings, backups.settings);
await plugins.update();
});
describe('settings.integratedChat = true', function () {
beforeEach(async function () {
settings.integratedChat = true;
});
it('appendChatMessage', async function () {
await agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` +
`&authorID=${authorID}&time=${timestamp}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to create chat message');
})
.expect(200)
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getChatHead', function () {
it('Gets the head of chat', function (done) {
agent.get(`${endPoint('getChatHead')}&padID=${padID}`)
.expect((res) => {
if (res.body.data.chatHead !== 0) throw new Error('Chat Head Length is wrong');
assert.equal(res.body.code, 0);
});
});
if (res.body.code !== 0) throw new Error('Unable to get chat head');
})
it('getChatHead', async function () {
await agent.get(`${endPoint('getChatHead')}&padID=${padID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getChatHistory', function () {
it('Gets Chat History of a Pad', function (done) {
agent.get(`${endPoint('getChatHistory')}&padID=${padID}`)
.expect((res) => {
if (res.body.data.messages.length !== 1) {
throw new Error('Chat History Length is wrong');
}
if (res.body.code !== 0) throw new Error('Unable to get chat history');
})
assert.equal(res.body.code, 0);
assert.equal(res.body.data.chatHead, 0);
});
});
it('getChatHistory', async function () {
await agent.get(`${endPoint('getChatHistory')}&padID=${padID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect(200, done);
.expect((res) => {
assert.equal(res.body.code, 0);
assert.equal(res.body.data.messages.length, 1);
});
});
});
describe('settings.integratedChat = false', function () {
beforeEach(async function () {
settings.integratedChat = false;
});
it('appendChatMessage returns an error', async function () {
await agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` +
`&authorID=${authorID}&time=${timestamp}`)
.expect(500)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 2);
});
});
it('getChatHead returns an error', async function () {
await agent.get(`${endPoint('getChatHead')}&padID=${padID}`)
.expect(500)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 2);
});
});
it('getChatHistory returns an error', async function () {
await agent.get(`${endPoint('getChatHistory')}&padID=${padID}`)
.expect(500)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 2);
});
});
});
});

View file

@ -1,9 +1,8 @@
'use strict';
/**
* caching_middleware is responsible for serving everything under path `/javascripts/`
* That includes packages as defined in `src/node/utils/tar.json` and probably also plugin code
*
* caching_middleware is responsible for serving everything under path `/javascripts/`. That
* includes packages as defined in `src/node/hooks/express/static.js` and probably also plugin code.
*/
const common = require('../common');

View file

@ -6,13 +6,17 @@ const assert = require('assert').strict;
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const pluginDefs = require('../../../static/js/pluginfw/plugin_defs');
const plugins = require('../../../static/js/pluginfw/plugins');
const settings = require('../../../node/utils/Settings');
const logger = common.logger;
const checkHook = async (hookName, checkFn) => {
if (pluginDefs.hooks[hookName] == null) pluginDefs.hooks[hookName] = [];
let hook;
try {
await new Promise((resolve, reject) => {
pluginDefs.hooks[hookName].push({
hook = {
hook_fn: async (hookName, context) => {
if (checkFn == null) return;
logger.debug(`hook ${hookName} invoked`);
@ -27,70 +31,71 @@ const checkHook = async (hookName, checkFn) => {
}
resolve();
},
};
pluginDefs.hooks[hookName].push(hook);
});
});
} finally {
pluginDefs.hooks[hookName] = pluginDefs.hooks[hookName].filter((h) => h !== hook);
}
};
const sendMessage = (socket, data) => {
socket.send({
type: 'COLLABROOM',
component: 'pad',
data,
});
};
const sendChat = (socket, message) => sendMessage(socket, {type: 'CHAT_MESSAGE', message});
const sendMessage = async (socket, data) => (
await common.sendMessage(socket, {type: 'COLLABROOM', component: 'pad', data}));
const sendChat = async (socket, message) => (
await sendMessage(socket, {type: 'CHAT_MESSAGE', message}));
describe(__filename, function () {
const backups = {settings: {}};
let clientVars;
const padId = 'testChatPad';
const hooksBackup = {};
let socket;
const connect = async () => {
socket = await common.connect();
({data: clientVars} = await common.handshake(socket, padId));
};
before(async function () {
for (const [name, defs] of Object.entries(pluginDefs.hooks)) {
if (defs == null) continue;
hooksBackup[name] = defs;
}
backups.settings.integratedChat = settings.integratedChat;
});
beforeEach(async function () {
for (const [name, defs] of Object.entries(hooksBackup)) pluginDefs.hooks[name] = [...defs];
for (const name of Object.keys(pluginDefs.hooks)) {
if (hooksBackup[name] == null) delete pluginDefs.hooks[name];
}
if (await padManager.doesPadExist(padId)) {
const pad = await padManager.getPad(padId);
await pad.remove();
}
});
after(async function () {
Object.assign(pluginDefs.hooks, hooksBackup);
for (const name of Object.keys(pluginDefs.hooks)) {
if (hooksBackup[name] == null) delete pluginDefs.hooks[name];
afterEach(async function () {
if (socket) {
socket.close();
socket = null;
}
});
describe('chatNewMessage hook', function () {
let authorId;
let socket;
after(async function () {
Object.assign(settings, backups.settings);
await plugins.update();
});
describe('settings.integratedChat = true', function () {
before(async function () {
settings.integratedChat = true;
await plugins.update();
});
beforeEach(async function () {
socket = await common.connect();
const {data: clientVars} = await common.handshake(socket, padId);
authorId = clientVars.userId;
});
afterEach(async function () {
socket.close();
await connect();
});
describe('chatNewMessage hook', function () {
it('message', async function () {
const start = Date.now();
await Promise.all([
checkHook('chatNewMessage', ({message}) => {
assert(message != null);
assert(message instanceof ChatMessage);
assert.equal(message.authorId, authorId);
assert.equal(message.authorId, clientVars.userId);
assert.equal(message.text, this.test.title);
assert(message.time >= start);
assert(message.time <= Date.now());
@ -158,3 +163,28 @@ describe(__filename, function () {
});
});
});
describe('settings.integratedChat = false', function () {
before(async function () {
settings.integratedChat = false;
await plugins.update();
});
beforeEach(async function () {
await connect();
});
it('clientVars.chatHead is unset', async function () {
assert(!('chatHead' in clientVars), `chatHead should be unset, is ${clientVars.chatHead}`);
});
it('rejects CHAT_MESSAGE messages', async function () {
await assert.rejects(sendChat(socket, {text: 'this is a test'}), /unknown message type/);
});
it('rejects GET_CHAT_MESSAGES messages', async function () {
const msg = {type: 'GET_CHAT_MESSAGES', start: 0, end: 0};
await assert.rejects(sendMessage(socket, msg), /unknown message type/);
});
});
});

View file

@ -57,46 +57,4 @@ describe('change user color', function () {
expect($colorPickerPreview.css('background-color')).to.be(testColorRGB);
expect($userSwatch.css('background-color')).to.be(testColorRGB);
});
it('Own user color is shown when you enter a chat', function (done) {
this.timeout(1000);
const chrome$ = helper.padChrome$;
const $colorOption = helper.padChrome$('#options-colorscheck');
if (!$colorOption.is(':checked')) {
$colorOption.click();
}
// click on the settings button to make settings visible
const $userButton = chrome$('.buttonicon-showusers');
$userButton.click();
const $userSwatch = chrome$('#myswatch');
$userSwatch.click();
const fb = chrome$.farbtastic('#colorpicker');
const $colorPickerSave = chrome$('#mycolorpickersave');
// Same color represented in two different ways
const testColorHash = '#abcdef';
const testColorRGB = 'rgb(171, 205, 239)';
fb.setColor(testColorHash);
$colorPickerSave.click();
// click on the chat button to make chat visible
const $chatButton = chrome$('#chaticon');
$chatButton.click();
const $chatInput = chrome$('#chatinput');
$chatInput.sendkeys('O hi'); // simulate a keypress of typing user
$chatInput.sendkeys('{enter}');
// wait until the chat message shows up
helper.waitFor(() => chrome$('#chattext').children('p').length !== 0).done(() => {
const $firstChatMessage = chrome$('#chattext').children('p');
// expect the first chat message to be of the user's color
expect($firstChatMessage.css('background-color')).to.be(testColorRGB);
done();
});
});
});

View file

@ -17,19 +17,4 @@ describe('change username value', function () {
await helper.toggleUserList();
await helper.waitForPromise(() => helper.usernameField().val() === '😃');
});
it('Own user name is shown when you enter a chat', async function () {
this.timeout(10000);
await helper.toggleUserList();
await helper.setUserName('😃');
await helper.showChat();
await helper.sendChatMessage('O hi{enter}');
await helper.waitForPromise(() => {
// username:hours:minutes text
const chatText = helper.chatTextParagraphs().text();
return chatText.indexOf('😃') === 0;
});
});
});

View file

@ -113,4 +113,61 @@ describe('Chat messages and UI', function () {
// chat should be visible.
expect(chaticon.is(':visible')).to.be(true);
});
it('Own user color is shown when you enter a chat', function (done) {
this.timeout(1000);
const chrome$ = helper.padChrome$;
const $colorOption = helper.padChrome$('#options-colorscheck');
if (!$colorOption.is(':checked')) {
$colorOption.click();
}
// click on the settings button to make settings visible
const $userButton = chrome$('.buttonicon-showusers');
$userButton.click();
const $userSwatch = chrome$('#myswatch');
$userSwatch.click();
const fb = chrome$.farbtastic('#colorpicker');
const $colorPickerSave = chrome$('#mycolorpickersave');
// Same color represented in two different ways
const testColorHash = '#abcdef';
const testColorRGB = 'rgb(171, 205, 239)';
fb.setColor(testColorHash);
$colorPickerSave.click();
// click on the chat button to make chat visible
const $chatButton = chrome$('#chaticon');
$chatButton.click();
const $chatInput = chrome$('#chatinput');
$chatInput.sendkeys('O hi'); // simulate a keypress of typing user
$chatInput.sendkeys('{enter}');
// wait until the chat message shows up
helper.waitFor(() => chrome$('#chattext').children('p').length !== 0).done(() => {
const $firstChatMessage = chrome$('#chattext').children('p');
// expect the first chat message to be of the user's color
expect($firstChatMessage.css('background-color')).to.be(testColorRGB);
done();
});
});
it('Own user name is shown when you enter a chat', async function () {
this.timeout(10000);
await helper.toggleUserList();
await helper.setUserName('😃');
await helper.showChat();
await helper.sendChatMessage('O hi{enter}');
await helper.waitForPromise(() => {
// username:hours:minutes text
const chatText = helper.chatTextParagraphs().text();
return chatText.indexOf('😃') === 0;
});
});
});