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 ### 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: * Improvements to login session management:
* `express_sid` cookies and `sessionstorage:*` database records are no longer * `express_sid` cookies and `sessionstorage:*` database records are no longer
created unless `requireAuthentication` is `true` (or a plugin causes them to 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` * New APIs for processing attributes: `ep_etherpad-lite/static/js/attributes`
(low-level API) and `ep_etherpad-lite/static/js/AttributeMap` (high-level (low-level API) and `ep_etherpad-lite/static/js/AttributeMap` (high-level
API). 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. * The `import` server-side hook has a new `ImportError` context property.
* New `exportEtherpad` and `importEtherpad` server-side hooks. * New `exportEtherpad` and `importEtherpad` server-side hooks.
* The `handleMessageSecurity` and `handleMessage` server-side hooks have a new * The `handleMessageSecurity` and `handleMessage` server-side hooks have a new
@ -89,6 +93,18 @@
instead. instead.
* `padUpdate`: The `author` context property is deprecated; use the new * `padUpdate`: The `author` context property is deprecated; use the new
`authorId` context property instead. Also, the hook now runs asynchronously. `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; * Returning `true` from a `handleMessageSecurity` hook function is deprecated;
return `'permitOnce'` instead. return `'permitOnce'` instead.
* Changes to the `src/static/js/Changeset.js` library: * 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 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. 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` 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 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", 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 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 `collab_client.js` has a pretty extensive list of message types, if you want to
take a look. 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 ## aceStartLineAndCharForPoint-aceEndLineAndCharForPoint
Called from: src/static/js/ace2_inner.js Called from: src/static/js/ace2_inner.js

View file

@ -1086,7 +1086,7 @@ exports.userLeave = async (hookName, {author, padId}) => {
## `chatNewMessage` ## `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 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. saved to the pad and relayed to all connected users.

View file

@ -80,15 +80,16 @@ The `settings.json.docker` available by default allows to control almost every s
### General ### General
| Variable | Description | Default | | Variable | Description | Default |
| ------------------ | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |--------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `TITLE` | The name of the instance | `Etherpad` | | `TITLE` | The name of the instance | `Etherpad` |
| `FAVICON` | favicon default name, or a fully specified URL to your own favicon | `favicon.ico` | | `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` | | `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` | | `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 |
| `PORT` | port which etherpad should bind at | `9001` | | `IP` | IP which etherpad should bind at. Change to `::` for IPv6 | `0.0.0.0` |
| `ADMIN_PASSWORD` | the password for the `admin` user (leave unspecified if you do not want to create it) | | | `PORT` | port which etherpad should bind at | `9001` |
| `USER_PASSWORD` | the password for the first user `user` (leave unspecified if you do not want to create it) | | | `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 ### Database

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}", "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. * Default Pad behavior.
* *
@ -231,6 +238,7 @@
"padOptions": { "padOptions": {
"noColors": "${PAD_OPTIONS_NO_COLORS:false}", "noColors": "${PAD_OPTIONS_NO_COLORS:false}",
"showControls": "${PAD_OPTIONS_SHOW_CONTROLS:true}", "showControls": "${PAD_OPTIONS_SHOW_CONTROLS:true}",
// To completely disable chat, set integratedChat to false.
"showChat": "${PAD_OPTIONS_SHOW_CHAT:true}", "showChat": "${PAD_OPTIONS_SHOW_CHAT:true}",
"showLineNumbers": "${PAD_OPTIONS_SHOW_LINE_NUMBERS:true}", "showLineNumbers": "${PAD_OPTIONS_SHOW_LINE_NUMBERS:true}",
"useMonospaceFont": "${PAD_OPTIONS_USE_MONOSPACE_FONT:false}", "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", "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. * Default Pad behavior.
* *
@ -232,6 +239,7 @@
"padOptions": { "padOptions": {
"noColors": false, "noColors": false,
"showControls": true, "showControls": true,
// To completely disable chat, set integratedChat to false.
"showChat": true, "showChat": true,
"showLineNumbers": true, "showLineNumbers": true,
"useMonospaceFont": false, "useMonospaceFont": false,

View file

@ -12,6 +12,33 @@
"shutdown": "ep_etherpad-lite/node/utils/Minify" "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", "name": "express",
"hooks": { "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 Changeset = require('../../static/js/Changeset');
const ChatMessage = require('../../static/js/ChatMessage');
const CustomError = require('../utils/customError'); const CustomError = require('../utils/customError');
const padManager = require('./PadManager'); const padManager = require('./PadManager');
const padMessageHandler = require('../handler/PadMessageHandler'); const padMessageHandler = require('../handler/PadMessageHandler');
@ -289,82 +288,7 @@ exports.setHTML = async (padID, html, authorId = '') => {
* CHAT FUNCTIONS * * CHAT FUNCTIONS *
**************** */ **************** */
/** exports.registerChatHandlers = (handlers) => Object.assign(exports, handlers);
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);
};
/* *************** /* ***************
* PAD FUNCTIONS * * PAD FUNCTIONS *
@ -732,20 +656,6 @@ Example returns:
exports.checkToken = async () => { 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 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 // 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 // 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 {padutils: {warnDeprecated}} = require('../../static/js/pad_utils');
const promises = require('../utils/promises'); 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 * Copied from the Etherpad source code. It converts Windows line breaks to Unix
* line breaks and convert Tabs to spaces * line breaks and convert Tabs to spaces
@ -45,7 +49,6 @@ class Pad {
this.atext = Changeset.makeAText('\n'); this.atext = Changeset.makeAText('\n');
this.pool = new AttributePool(); this.pool = new AttributePool();
this.head = -1; this.head = -1;
this.chatHead = -1;
this.publicStatus = false; this.publicStatus = false;
this.id = id; this.id = id;
this.savedRevisions = []; this.savedRevisions = [];
@ -287,6 +290,7 @@ class Pad {
/** /**
* Adds a chat message to the pad, including saving it to the database. * 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 * @param {(ChatMessage|string)} msgOrText - Either a chat message object (recommended) or a
* string containing the raw text of the user's chat message (deprecated). * string containing the raw text of the user's chat message (deprecated).
* @param {?string} [authorId] - The user's author ID. Deprecated; use `msgOrText.authorId` * @param {?string} [authorId] - The user's author ID. Deprecated; use `msgOrText.authorId`
@ -295,31 +299,24 @@ class Pad {
* `msgOrText.time` instead. * `msgOrText.time` instead.
*/ */
async appendChatMessage(msgOrText, authorId = null, time = null) { async appendChatMessage(msgOrText, authorId = null, time = null) {
warnDeprecated('Pad.appendChatMessage() is deprecated');
const msg = const msg =
msgOrText instanceof ChatMessage ? msgOrText : new ChatMessage(msgOrText, authorId, time); msgOrText instanceof ChatMessage ? msgOrText : new ChatMessage(msgOrText, authorId, time);
this.chatHead++; await chat.appendChatMessage(this, msg);
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(),
]);
} }
/** /**
* @deprecated
* @param {number} entryNum - ID of the desired chat message. * @param {number} entryNum - ID of the desired chat message.
* @returns {?ChatMessage} * @returns {?ChatMessage}
*/ */
async getChatMessage(entryNum) { async getChatMessage(entryNum) {
const entry = await this.db.get(`pad:${this.id}:chat:${entryNum}`); warnDeprecated('Pad.getChatMessage() is deprecated');
if (entry == null) return null; return await chat.getChatMessage(this, entryNum);
const message = ChatMessage.fromObject(entry);
message.displayName = await authorManager.getAuthorName(message.authorId);
return message;
} }
/** /**
* @deprecated
* @param {number} start - ID of the first desired chat message. * @param {number} start - ID of the first desired chat message.
* @param {number} end - ID of the last 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` * @returns {ChatMessage[]} Any existing messages with IDs between `start` (inclusive) and `end`
@ -327,19 +324,8 @@ class Pad {
* interval as is typical in code. * interval as is typical in code.
*/ */
async getChatMessages(start, end) { async getChatMessages(start, end) {
const entries = warnDeprecated('Pad.getChatMessages() is deprecated');
await Promise.all(Stream.range(start, end + 1).map(this.getChatMessage.bind(this))); return await chat.getChatMessages(this, start, end);
// 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;
});
} }
async init(text, authorId = '') { async init(text, authorId = '') {
@ -386,7 +372,6 @@ class Pad {
const promises = (function* () { const promises = (function* () {
yield copyRecord(''); yield copyRecord('');
yield* Stream.range(0, this.head + 1).map((i) => copyRecord(`:revs:${i}`)); 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); yield this.copyAuthorInfoToDestinationPad(destinationID);
if (destGroupID) yield db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1); if (destGroupID) yield db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1);
}).call(this); }).call(this);
@ -545,11 +530,6 @@ class Pad {
})); }));
p.push(db.remove(`pad2readonly:${padID}`)); 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 // delete all revisions
p.push(promises.timesLimit(this.head + 1, 500, async (i) => { p.push(promises.timesLimit(this.head + 1, 500, async (i) => {
await this.db.remove(`pad:${this.id}:revs:${i}`, null); await this.db.remove(`pad:${this.id}:revs:${i}`, null);
@ -703,23 +683,6 @@ class Pad {
assert.deepEqual(this.atext, atext); assert.deepEqual(this.atext, atext);
assert.deepEqual(this.getAllAuthors().sort(), [...authorIds].sort()); 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}); await hooks.aCallAll('padCheck', {pad: this});
} }
} }

View file

@ -40,6 +40,7 @@ const assert = require('assert').strict;
const {RateLimiterMemory} = require('rate-limiter-flexible'); const {RateLimiterMemory} = require('rate-limiter-flexible');
const webaccess = require('../hooks/express/webaccess'); const webaccess = require('../hooks/express/webaccess');
let chat = null;
let rateLimiter; let rateLimiter;
let socketio = null; let socketio = null;
@ -54,6 +55,8 @@ const addContextToError = (err, pfx) => {
return err; return err;
}; };
exports.registerLegacyChatHandlers = (handlers) => chat = handlers;
exports.socketio = () => { exports.socketio = () => {
// The rate limiter is created in this hook so that restarting the server resets the limiter. The // 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 // 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}); await padChannels.enqueue(thisSession.padId, {socket, message});
break; break;
case 'USERINFO_UPDATE': await handleUserInfoUpdate(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 'SAVE_REVISION': await handleSaveRevisionMessage(socket, message); break;
case 'CLIENT_MESSAGE': { case 'CLIENT_MESSAGE': {
const {type} = message.data.payload; const {type} = message.data.payload;
@ -413,23 +414,10 @@ exports.handleCustomMessage = (padID, msgString) => {
socketio.sockets.in(padID).json.send(msg); 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. * 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 * @param {(ChatMessage|number)} mt - Either a chat message object (recommended) or the timestamp of
* the chat message in ms since epoch (deprecated). * the chat message in ms since epoch (deprecated).
* @param {string} puId - If `mt` is a chat message object, this is the destination pad ID. * @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. * object as the first argument and the destination pad ID as the second argument instead.
*/ */
exports.sendChatMessageToPadClients = async (mt, puId, text = null, padId = null) => { 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); const message = mt instanceof ChatMessage ? mt : new ChatMessage(text, puId, mt);
padId = mt instanceof ChatMessage ? puId : padId; padId = mt instanceof ChatMessage ? puId : padId;
const pad = await padManager.getPad(padId, null, message.authorId); await chat.sendChatMessageToPadClients(message, padId);
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);
}; };
/** /**
@ -949,9 +903,6 @@ const handleClientReady = async (socket, message) => {
padShortcutEnabled: settings.padShortcutEnabled, padShortcutEnabled: settings.padShortcutEnabled,
initialTitle: `Pad: ${sessionInfo.auth.padID}`, initialTitle: `Pad: ${sessionInfo.auth.padID}`,
opts: {}, 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, numConnectedUsers: roomSockets.length,
readOnlyId: sessionInfo.readOnlyPadId, readOnlyId: sessionInfo.readOnlyPadId,
readonly: sessionInfo.readonly, readonly: sessionInfo.readonly,

View file

@ -1,15 +1,115 @@
'use strict'; 'use strict';
const fs = require('fs').promises;
const minify = require('../../utils/Minify'); const minify = require('../../utils/Minify');
const path = require('path');
const plugins = require('../../../static/js/pluginfw/plugin_defs'); const plugins = require('../../../static/js/pluginfw/plugin_defs');
const settings = require('../../utils/Settings'); const settings = require('../../utils/Settings');
const CachingMiddleware = require('../../utils/caching_middleware'); const CachingMiddleware = require('../../utils/caching_middleware');
const Yajsml = require('etherpad-yajsml'); const Yajsml = require('etherpad-yajsml');
// Rewrite tar to include modules with no extensions and proper rooted paths. const tar = (() => {
const getTar = async () => { 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) => { const prefixLocalLibraryPath = (path) => {
if (path.charAt(0) === '$') { if (path.charAt(0) === '$') {
return path.slice(1); return path.slice(1);
@ -17,16 +117,15 @@ const getTar = async () => {
return `ep_etherpad-lite/static/js/${path}`; return `ep_etherpad-lite/static/js/${path}`;
} }
}; };
const tarJson = await fs.readFile(path.join(settings.root, 'src/node/utils/tar.json'), 'utf8');
const tar = {}; 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); const files = relativeFiles.map(prefixLocalLibraryPath);
tar[prefixLocalLibraryPath(key)] = files tar[prefixLocalLibraryPath(key)] = files
.concat(files.map((p) => p.replace(/\.js$/, ''))) .concat(files.map((p) => p.replace(/\.js$/, '')))
.concat(files.map((p) => `${p.replace(/\.js$/, '')}/index.js`)); .concat(files.map((p) => `${p.replace(/\.js$/, '')}/index.js`));
} }
return tar; return tar;
}; })();
exports.expressPreSession = async (hookName, {app}) => { exports.expressPreSession = async (hookName, {app}) => {
// Cache both minified and static. // Cache both minified and static.
@ -49,7 +148,7 @@ exports.expressPreSession = async (hookName, {app}) => {
}); });
const StaticAssociator = Yajsml.associators.StaticAssociator; const StaticAssociator = Yajsml.associators.StaticAssociator;
const associations = Yajsml.associators.associationsForSimpleMapping(await getTar()); const associations = Yajsml.associators.associationsForSimpleMapping(tar);
const associator = new StaticAssociator(associations); const associator = new StaticAssociator(associations);
jsServer.setAssociator(associator); jsServer.setAssociator(associator);
@ -59,10 +158,22 @@ exports.expressPreSession = async (hookName, {app}) => {
// not very static, but served here so that client can do // not very static, but served here so that client can do
// require("pluginfw/static/js/plugin-definitions.js"); // require("pluginfw/static/js/plugin-definitions.js");
app.get('/pluginfw/plugin-definitions.json', (req, res, next) => { 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 = {}; const clientPlugins = {};
for (const name of new Set(clientParts.map((part) => part.plugin))) { 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; delete clientPlugins[name].package;
} }
res.setHeader('Content-Type', 'application/json; charset=utf-8'); 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; if (!pluginPath.endsWith(path.sep)) pluginPath += path.sep;
const specDir = `${plugin === 'ep_etherpad-lite' ? '' : 'static/'}tests/frontend/specs`; const specDir = `${plugin === 'ep_etherpad-lite' ? '' : 'static/'}tests/frontend/specs`;
for (const spec of await findSpecs(path.join(pluginPath, specDir))) { for (const spec of await findSpecs(path.join(pluginPath, specDir))) {
if (plugin === 'ep_etherpad-lite' && !settings.enableAdminUITests && if (plugin === 'ep_etherpad-lite') {
spec.startsWith('admin')) continue; if (!settings.enableAdminUITests && spec.startsWith('admin')) continue;
if (!settings.integratedChat && spec.startsWith('chat')) continue;
}
modules.push(`${plugin}/${specDir}/${spec.replace(/\.js$/, '')}`); modules.push(`${plugin}/${specDir}/${spec.replace(/\.js$/, '')}`);
} }
})); }));

View file

@ -48,7 +48,6 @@ const UpdateCheck = require('./utils/UpdateCheck');
const db = require('./db/DB'); const db = require('./db/DB');
const express = require('./hooks/express'); const express = require('./hooks/express');
const hooks = require('../static/js/pluginfw/hooks'); const hooks = require('../static/js/pluginfw/hooks');
const pluginDefs = require('../static/js/pluginfw/plugin_defs');
const plugins = require('../static/js/pluginfw/plugins'); const plugins = require('../static/js/pluginfw/plugins');
const {Gate} = require('./utils/promises'); const {Gate} = require('./utils/promises');
const stats = require('./stats'); const stats = require('./stats');
@ -134,13 +133,6 @@ exports.start = async () => {
await db.init(); await db.init();
await plugins.update(); 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('loadSettings', {settings});
await hooks.aCallAll('createServer'); await hooks.aCallAll('createServer');
} catch (err) { } 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.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; for (const gen of pluginRecords) yield* gen;
})(); })();
const data = {[dstPfx]: pad}; const data = {[dstPfx]: pad};

View file

@ -74,7 +74,9 @@ exports.setPadRaw = async (padId, r, authorId = '') => {
return; return;
} }
value.padIDs = {[padId]: 1}; 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); checkOriginalPadId(id);
if (prefix === 'pad' && keyParts.length === 2) { if (prefix === 'pad' && keyParts.length === 2) {
const pool = new AttributePool().fromJsonable(value.pool); const pool = new AttributePool().fromJsonable(value.pool);

View file

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

View file

@ -156,6 +156,12 @@ exports.defaultPadText = [
'Etherpad on Github: https://github.com/ether/etherpad-lite', 'Etherpad on Github: https://github.com/ether/etherpad-lite',
].join('\n'); ].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 * 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%; max-width: 40%;
flex-shrink: 0; 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 */ width: 0; /* hide when the container is empty */
} }

View file

@ -2585,15 +2585,6 @@ function Ace2Inner(editorInfo, cssManagers) {
firstEditbarElement.focus(); firstEditbarElement.focus();
evt.preventDefault(); 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' && if (!specialHandled && type === 'keydown' &&
evt.ctrlKey && shiftKey && keyCode === 50 && evt.ctrlKey && shiftKey && keyCode === 50 &&
padShortcutEnabled.cmdShift2) { 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 // Removes diacritics and lower-cases letters. https://stackoverflow.com/a/37511463
const normalize = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); const normalize = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
exports.chat = (() => { const chat = (() => {
let isStuck = false; let isStuck = false;
let userAndChat = false; let userAndChat = false;
let chatMentions = 0; let chatMentions = 0;
@ -79,17 +79,35 @@ exports.chat = (() => {
.toggleClass('chatAndUsers popup-show stickyUsers', userAndChat); .toggleClass('chatAndUsers popup-show stickyUsers', userAndChat);
$('#chatbox').toggleClass('chatAndUsersChat', userAndChat); $('#chatbox').toggleClass('chatAndUsersChat', userAndChat);
}, },
hide() { reduce() {
// decide on hide logic based on chat window being maximized or not // decide on hide logic based on chat window being maximized or not
if ($('#options-stickychat').prop('checked')) { if ($('#options-stickychat').prop('checked')) {
this.stickToScreen(); this.stickToScreen();
$('#options-stickychat').prop('checked', false); $('#options-stickychat').prop('checked', false);
} else { } else {
$('#chatcounter').text('0'); $('#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'); $('#chaticon').addClass('visible');
$('#chatbox').removeClass('visible'); $('#chatbox').removeClass('visible');
} }
}, },
minimize() {
if ($('#options-stickychat').prop('checked')) this.reduce();
this.reduce();
},
hide() {
this.minimize();
$('#chaticon').hide();
},
scrollDown(force) { scrollDown(force) {
if ($('#chatbox').hasClass('visible')) { if ($('#chatbox').hasClass('visible')) {
if (force || !this.lastMessage || !this.lastMessage.position() || if (force || !this.lastMessage || !this.lastMessage.position() ||
@ -218,6 +236,11 @@ exports.chat = (() => {
}, },
init(pad) { init(pad) {
this._pad = 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) => { $('#chatinput').on('keydown', (evt) => {
// If the event is Alt C or Escape & we're already in the chat menu // If the event is Alt C or Escape & we're already in the chat menu
// Send the users focus back to the pad // Send the users focus back to the pad
@ -235,17 +258,6 @@ exports.chat = (() => {
Tinycon.setBubble(0); 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) => { $('#chatinput').keypress((evt) => {
// if the user typed enter, fire the send // if the user typed enter, fire the send
if (evt.key === 'Enter' && !evt.shiftKey) { if (evt.key === 'Enter' && !evt.shiftKey) {
@ -269,6 +281,110 @@ exports.chat = (() => {
pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end}); pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end});
this.historyPointer = start; 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. * limitations under the License.
*/ */
const chat = require('./chat').chat;
const hooks = require('./pluginfw/hooks'); const hooks = require('./pluginfw/hooks');
const browser = require('./vendors/browser'); const browser = require('./vendors/browser');
@ -266,28 +265,6 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
} }
} else if (msg.type === 'CLIENT_MESSAGE') { } else if (msg.type === 'CLIENT_MESSAGE') {
callbacks.onClientMessage(msg.payload); 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 // 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 // Similar for NEW_CHANGES
if (msg.type === 'NEW_CHANGES') msg.payload = msg; 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) => { const updateUserInfo = (userInfo) => {

View file

@ -31,7 +31,6 @@ require('./vendors/farbtastic');
require('./vendors/gritter'); require('./vendors/gritter');
const Cookies = require('./pad_utils').Cookies; const Cookies = require('./pad_utils').Cookies;
const chat = require('./chat').chat;
const getCollabClient = require('./collab_client').getCollabClient; const getCollabClient = require('./collab_client').getCollabClient;
const padconnectionstatus = require('./pad_connectionstatus').padconnectionstatus; const padconnectionstatus = require('./pad_connectionstatus').padconnectionstatus;
const padcookie = require('./pad_cookie').padcookie; const padcookie = require('./pad_cookie').padcookie;
@ -70,17 +69,6 @@ const getParameters = [
$('#editbar').css('display', 'flex'); $('#editbar').css('display', 'flex');
}, },
}, },
{
name: 'showChat',
checkVal: null,
callback: (val) => {
if (val === 'false') {
settings.hideChat = true;
chat.hide();
$('#chaticon').hide();
}
},
},
{ {
name: 'showLineNumbers', name: 'showLineNumbers',
checkVal: 'false', checkVal: 'false',
@ -118,20 +106,6 @@ const getParameters = [
settings.rtlIsTrue = true; settings.rtlIsTrue = true;
}, },
}, },
{
name: 'alwaysShowChat',
checkVal: 'true',
callback: (val) => {
if (!settings.hideChat) chat.stickToScreen();
},
},
{
name: 'chatAndUsers',
checkVal: 'true',
callback: (val) => {
chat.chatAndUsers();
},
},
{ {
name: 'lang', name: 'lang',
checkVal: null, checkVal: null,
@ -392,8 +366,6 @@ const pad = {
}, },
_afterHandshake() { _afterHandshake() {
pad.clientTimeOffset = Date.now() - clientVars.serverTimestamp; pad.clientTimeOffset = Date.now() - clientVars.serverTimestamp;
// initialize the chat
chat.init(this);
getParams(); getParams();
padcookie.init(); // initialize the cookies padcookie.init(); // initialize the cookies
@ -412,16 +384,6 @@ const pad = {
setTimeout(() => { setTimeout(() => {
padeditor.ace.focus(); padeditor.ace.focus();
}, 0); }, 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) { if (padcookie.getPref('showAuthorshipColors') === false) {
pad.changeViewOption('showAuthorColors', false); pad.changeViewOption('showAuthorColors', false);
} }
@ -434,17 +396,6 @@ const pad = {
pad.changeViewOption('padFontFamily', padcookie.getPref('padFontFamily')); pad.changeViewOption('padFontFamily', padcookie.getPref('padFontFamily'));
$('#viewfontmenu').val(padcookie.getPref('padFontFamily')).niceSelect('update'); $('#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'); $('#editorcontainer').addClass('initialized');
hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad}); hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad});
@ -469,24 +420,7 @@ const pad = {
pad.collabClient.setOnChannelStateChange(pad.handleChannelStateChange); pad.collabClient.setOnChannelStateChange(pad.handleChannelStateChange);
pad.collabClient.setOnInternalAction(pad.handleCollabAction); pad.collabClient.setOnInternalAction(pad.handleCollabAction);
// load initial chat-messages if (window.clientVars.readonly) $('#myusernameedit').attr('disabled', true);
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(); }
$('body').addClass(window.clientVars.readonly ? 'readonly' : 'readwrite'); $('body').addClass(window.clientVars.readonly ? 'readonly' : 'readwrite');
@ -650,31 +584,11 @@ const pad = {
} }
}, },
handleIsFullyConnected: (isConnected, isInitialConnect) => { handleIsFullyConnected: (isConnected, isInitialConnect) => {
pad.determineChatVisibility(isConnected && !isInitialConnect);
pad.determineChatAndUsersVisibility(isConnected && !isInitialConnect);
pad.determineAuthorshipColorsVisibility(); pad.determineAuthorshipColorsVisibility();
setTimeout(() => { setTimeout(() => {
padeditbar.toggleDropDown('none'); padeditbar.toggleDropDown('none');
}, 1000); }, 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: () => { determineAuthorshipColorsVisibility: () => {
const authColCookie = padcookie.getPref('showAuthorshipColors'); const authColCookie = padcookie.getPref('showAuthorshipColors');
if (authColCookie) { if (authColCookie) {

View file

@ -5,6 +5,7 @@ const hooks = require('./hooks');
const log4js = require('log4js'); const log4js = require('log4js');
const path = require('path'); const path = require('path');
const runCmd = require('../../../node/utils/run_cmd'); const runCmd = require('../../../node/utils/run_cmd');
const settings = require('../../../node/utils/Settings');
const tsort = require('./tsort'); const tsort = require('./tsort');
const pluginUtils = require('./shared'); const pluginUtils = require('./shared');
const defs = require('./plugin_defs'); const defs = require('./plugin_defs');
@ -102,6 +103,13 @@ exports.update = async () => {
const logger = log4js.getLogger(`plugin:${p}`); const logger = log4js.getLogger(`plugin:${p}`);
await hooks.aCallAll(`init_${p}`, {logger}); 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 () => { exports.getPackages = async () => {
@ -129,6 +137,9 @@ const loadPlugin = async (packages, pluginName, plugins, parts) => {
const data = await fs.readFile(pluginPath); const data = await fs.readFile(pluginPath);
try { try {
const plugin = JSON.parse(data); 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]; plugin.package = packages[pluginName];
plugins[pluginName] = plugin; plugins[pluginName] = plugin;
for (const part of plugin.parts) { for (const part of plugin.parts) {

View file

@ -62,348 +62,321 @@
<!--------- TOOLBAR -----------> <!--------- TOOLBAR ----------->
<!-----------------------------> <!----------------------------->
<div id="editbar" class="toolbar"> <div id="editbar" class="toolbar">
<div id="toolbar-overlay"></div> <div id="toolbar-overlay"></div>
<ul class="menu_left" role="toolbar"> <ul class="menu_left" role="toolbar">
<% e.begin_block("editbarMenuLeft"); %> <% e.begin_block("editbarMenuLeft"); %>
<%- toolbar.menu(settings.toolbar.left, isReadOnly, 'left', 'pad') %> <%- toolbar.menu(settings.toolbar.left, isReadOnly, 'left', 'pad') %>
<% e.end_block(); %> <% e.end_block(); %>
</ul> </ul>
<ul class="menu_right" role="toolbar"> <ul class="menu_right" role="toolbar">
<% e.begin_block("editbarMenuRight"); %> <% e.begin_block("editbarMenuRight"); %>
<%- toolbar.menu(settings.toolbar.right, isReadOnly, 'right', 'pad') %> <%- toolbar.menu(settings.toolbar.right, isReadOnly, 'right', 'pad') %>
<% e.end_block(); %> <% e.end_block(); %>
</ul> </ul>
<span class="show-more-icon-btn"></span> <!-- use on small screen to display hidden toolbar buttons --> <span class="show-more-icon-btn"></span> <!-- use on small screen to display hidden toolbar buttons -->
</div> </div>
<% e.begin_block("afterEditbar"); %><% e.end_block(); %> <% e.begin_block("afterEditbar"); %><% e.end_block(); %>
<div id="editorcontainerbox" class="flex-layout"> <div id="editorcontainerbox" class="flex-layout">
<% e.begin_block("editorContainerBox"); %> <% e.begin_block("editorContainerBox"); %>
<!-----------------------------> <!----------------------------->
<!--- PAD EDITOR (in iframe) --> <!--- PAD EDITOR (in iframe) -->
<!-----------------------------> <!----------------------------->
<div id="editorcontainer" class="editorcontainer"></div> <div id="editorcontainer" class="editorcontainer"></div>
<div id="editorloadingbox"> <div id="editorloadingbox">
<% e.begin_block("permissionDenied"); %> <% e.begin_block("permissionDenied"); %>
<div id="permissionDenied"> <div id="permissionDenied">
<p data-l10n-id="pad.permissionDenied" class="editorloadingbox-message"> <p data-l10n-id="pad.permissionDenied" class="editorloadingbox-message">
You do not have permission to access this pad You do not have permission to access this pad
</p> </p>
</div> </div>
<% e.end_block(); %> <% e.end_block(); %>
<% e.begin_block("loading"); %> <% e.begin_block("loading"); %>
<p data-l10n-id="pad.loading" id="loading" class="editorloadingbox-message"> <p data-l10n-id="pad.loading" id="loading" class="editorloadingbox-message">
<img src='../static/img/brand.svg' class='etherpadBrand'><br/> <img src='../static/img/brand.svg' class='etherpadBrand'><br/>
Loading... Loading...
</p>
<% e.end_block(); %>
<noscript>
<p class="editorloadingbox-message">
<strong>
Sorry, you have to enable Javascript in order to use this.
</strong>
</p>
</noscript>
</div>
<!-------------------------------------------->
<!-- SETTINGS POPUP (change font, language) -->
<!-------------------------------------------->
<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>
<input type="checkbox" id="options-colorscheck">
<label for="options-colorscheck" data-l10n-id="pad.settings.colorcheck"></label>
</p>
<p>
<input type="checkbox" id="options-linenoscheck" checked>
<label for="options-linenoscheck" data-l10n-id="pad.settings.linenocheck"></label>
</p>
<p>
<input type="checkbox" id="options-rtlcheck">
<label for="options-rtlcheck" data-l10n-id="pad.settings.rtlcheck"></label>
</p> </p>
<% e.end_block(); %> <% e.end_block(); %>
<noscript>
<p class="editorloadingbox-message">
<strong>
Sorry, you have to enable Javascript in order to use this.
</strong>
</p>
</noscript>
</div>
<div class="dropdowns-container">
<% e.begin_block("mySettings.dropdowns"); %>
<p class="dropdown-line">
<label for="viewfontmenu" data-l10n-id="pad.settings.fontType">Font type:</label>
<select id="viewfontmenu">
<option value="" data-l10n-id="pad.settings.fontType.normal">Normal</option>
<%= fonts = ["Quicksand", "Roboto", "Alegreya", "PlayfairDisplay", "Montserrat", "OpenDyslexic", "RobotoMono"] %>
<% for(var i=0; i < fonts.length; i++) { %>
<option value="<%=fonts[i]%>"><%=fonts[i]%></option>
<% } %>
</select>
</p>
<!-------------------------------------------------------------> <p class="dropdown-line">
<!-- SETTINGS POPUP (change font, language, chat parameters) --> <label for="languagemenu" data-l10n-id="pad.settings.language">Language:</label>
<!-------------------------------------------------------------> <select id="languagemenu">
<% for (lang in langs) { %>
<div id="settings" class="popup"><div class="popup-content"> <option value="<%=lang%>"><%=langs[lang].nativeName%></option>
<h1 data-l10n-id="pad.settings.padSettings"></h1> <% } %>
<% e.begin_block("mySettings"); %> </select>
<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>
</p>
<p>
<input type="checkbox" id="options-linenoscheck" checked>
<label for="options-linenoscheck" data-l10n-id="pad.settings.linenocheck"></label>
</p>
<p>
<input type="checkbox" id="options-rtlcheck">
<label for="options-rtlcheck" data-l10n-id="pad.settings.rtlcheck"></label>
</p> </p>
<% e.end_block(); %> <% e.end_block(); %>
</div>
<div class="dropdowns-container"> <h2 data-l10n-id="pad.settings.about">About</h2>
<% e.begin_block("mySettings.dropdowns"); %> <span data-l10n-id="pad.settings.poweredBy">Powered by</span>
<p class="dropdown-line"> <a href="https://etherpad.org">Etherpad</a>
<label for="viewfontmenu" data-l10n-id="pad.settings.fontType">Font type:</label> <% if (settings.exposeVersion) { %>(commit <%=settings.getGitCommit()%>)<% } %>
<select id="viewfontmenu"> </div>
<option value="" data-l10n-id="pad.settings.fontType.normal">Normal</option> </div>
<%= fonts = ["Quicksand", "Roboto", "Alegreya", "PlayfairDisplay", "Montserrat", "OpenDyslexic", "RobotoMono"] %>
<% for(var i=0; i < fonts.length; i++) { %>
<option value="<%=fonts[i]%>"><%=fonts[i]%></option>
<% } %>
</select>
</p>
<p class="dropdown-line"> <!------------------------->
<label for="languagemenu" data-l10n-id="pad.settings.language">Language:</label> <!-- IMPORT EXPORT POPUP -->
<select id="languagemenu"> <!------------------------->
<% for (lang in langs) { %>
<option value="<%=lang%>"><%=langs[lang].nativeName%></option>
<% } %>
</select>
</p>
<% e.end_block(); %>
</div>
<h2 data-l10n-id="pad.settings.about">About</h2> <div id="import_export" class="popup">
<span data-l10n-id="pad.settings.poweredBy">Powered by</span> <div class="popup-content">
<a href="https://etherpad.org">Etherpad</a> <h1 data-l10n-id="pad.importExport.import_export"></h1>
<% if (settings.exposeVersion) { %>(commit <%=settings.getGitCommit()%>)<% } %> <div class="acl-write">
</div></div> <% e.begin_block("importColumn"); %>
<h2 data-l10n-id="pad.importExport.import"></h2>
<div class="importmessage" id="importmessageabiword" data-l10n-id="pad.importExport.abiword.innerHTML"></div><br>
<!-------------------------> <form id="importform" method="post" action="" target="importiframe" enctype="multipart/form-data">
<!-- IMPORT EXPORT POPUP --> <div class="importformdiv" id="importformfilediv">
<!-------------------------> <input type="file" name="file" size="10" id="importfileinput">
<div class="importmessage" id="importmessagefail"></div>
<div id="import_export" class="popup"><div class="popup-content"> </div>
<h1 data-l10n-id="pad.importExport.import_export"></h1> <div id="import"></div>
<div class="acl-write"> <div class="importmessage" id="importmessagesuccess" data-l10n-id="pad.importExport.importSuccessful"></div>
<% e.begin_block("importColumn"); %> <div class="importformdiv" id="importformsubmitdiv">
<h2 data-l10n-id="pad.importExport.import"></h2> <span class="nowrap">
<div class="importmessage" id="importmessageabiword" data-l10n-id="pad.importExport.abiword.innerHTML"></div><br> <input type="submit" class="btn btn-primary" name="submit" value="Import Now" disabled="disabled" id="importsubmitinput">
<form id="importform" method="post" action="" target="importiframe" enctype="multipart/form-data"> <div alt="" id="importstatusball" class="loadingAnimation" align="top"></div>
<div class="importformdiv" id="importformfilediv"> </span>
<input type="file" name="file" size="10" id="importfileinput"> </div>
<div class="importmessage" id="importmessagefail"></div>
</div>
<div id="import"></div>
<div class="importmessage" id="importmessagesuccess" data-l10n-id="pad.importExport.importSuccessful"></div>
<div class="importformdiv" id="importformsubmitdiv">
<span class="nowrap">
<input type="submit" class="btn btn-primary" name="submit" value="Import Now" disabled="disabled" id="importsubmitinput">
<div alt="" id="importstatusball" class="loadingAnimation" align="top"></div>
</span>
</div>
</form>
<% e.end_block(); %>
</div>
<div id="exportColumn">
<h2 data-l10n-id="pad.importExport.export"></h2>
<% e.begin_block("exportColumn"); %>
<a id="exportetherpada" target="_blank" class="exportlink">
<span class="exporttype buttonicon buttonicon-file-powerpoint" id="exportetherpad" data-l10n-id="pad.importExport.exportetherpad"></span>
</a>
<a id="exporthtmla" target="_blank" class="exportlink">
<span class="exporttype buttonicon buttonicon-file-code" id="exporthtml" data-l10n-id="pad.importExport.exporthtml"></span>
</a>
<a id="exportplaina" target="_blank" class="exportlink">
<span class="exporttype buttonicon buttonicon-file" id="exportplain" data-l10n-id="pad.importExport.exportplain"></span>
</a>
<a id="exportworda" target="_blank" class="exportlink">
<span class="exporttype buttonicon buttonicon-file-word" id="exportword" data-l10n-id="pad.importExport.exportword"></span>
</a>
<a id="exportpdfa" target="_blank" class="exportlink">
<span class="exporttype buttonicon buttonicon-file-pdf" id="exportpdf" data-l10n-id="pad.importExport.exportpdf"></span>
</a>
<a id="exportopena" target="_blank" class="exportlink">
<span class="exporttype buttonicon buttonicon-file-alt" id="exportopen" data-l10n-id="pad.importExport.exportopen"></span>
</a>
<% e.end_block(); %>
</div>
</div></div>
<!---------------------------------------------------->
<!-- CONNECTIVITY POPUP (when you get disconnected) -->
<!---------------------------------------------------->
<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>
</div>
<div class="reconnecting">
<h1 data-l10n-id="pad.modals.reconnecting"></h1>
<i class='buttonicon buttonicon-spin5 icon-spin'>
<img src='../static/img/brand.svg' class='etherpadBrand'><br/>
</i>
</div>
<div class="userdup">
<h1 data-l10n-id="pad.modals.userdup"></h1>
<h2 data-l10n-id="pad.modals.userdup.explanation"></h2>
<p id="defaulttext" data-l10n-id="pad.modals.userdup.advice"></p>
<button id="forcereconnect" class="btn btn-primary" data-l10n-id="pad.modals.forcereconnect"></button>
</div>
<div class="unauth">
<h1 data-l10n-id="pad.modals.unauth"></h1>
<p id="defaulttext" data-l10n-id="pad.modals.unauth.explanation"></p>
<button id="forcereconnect" class="btn btn-primary" data-l10n-id="pad.modals.forcereconnect"></button>
</div>
<div class="looping">
<h1 data-l10n-id="pad.modals.disconnected"></h1>
<h2 data-l10n-id="pad.modals.looping.explanation"></h2>
<p data-l10n-id="pad.modals.looping.cause"></p>
</div>
<div class="initsocketfail">
<h1 data-l10n-id="pad.modals.initsocketfail"></h1>
<h2 data-l10n-id="pad.modals.initsocketfail.explanation"></h2>
<p data-l10n-id="pad.modals.initsocketfail.cause"></p>
</div>
<div class="slowcommit with_reconnect_timer">
<h1 data-l10n-id="pad.modals.disconnected"></h1>
<h2 data-l10n-id="pad.modals.slowcommit.explanation"></h2>
<p id="defaulttext" data-l10n-id="pad.modals.slowcommit.cause"></p>
<button id="forcereconnect" class="btn btn-primary" data-l10n-id="pad.modals.forcereconnect"></button>
</div>
<div class="badChangeset with_reconnect_timer">
<h1 data-l10n-id="pad.modals.disconnected"></h1>
<h2 data-l10n-id="pad.modals.badChangeset.explanation"></h2>
<p id="defaulttext" data-l10n-id="pad.modals.badChangeset.cause"></p>
<button id="forcereconnect" class="btn btn-primary" data-l10n-id="pad.modals.forcereconnect"></button>
</div>
<div class="corruptPad">
<h1 data-l10n-id="pad.modals.disconnected"></h1>
<h2 data-l10n-id="pad.modals.corruptPad.explanation"></h2>
<p data-l10n-id="pad.modals.corruptPad.cause"></p>
</div>
<div class="deleted">
<h1 data-l10n-id="pad.modals.deleted"></h1>
<p data-l10n-id="pad.modals.deleted.explanation"></p>
</div>
<div class="rateLimited">
<h1 data-l10n-id="pad.modals.rateLimited"></h1>
<p data-l10n-id="pad.modals.rateLimited.explanation"></p>
</div>
<div class="rejected">
<h1 data-l10n-id="pad.modals.disconnected"></h1>
<h2 data-l10n-id="pad.modals.rejected.explanation"></h2>
<p data-l10n-id="pad.modals.rejected.cause"></p>
</div>
<div class="disconnected with_reconnect_timer">
<% e.begin_block("disconnected"); %>
<h1 data-l10n-id="pad.modals.disconnected"></h1>
<h2 data-l10n-id="pad.modals.disconnected.explanation"></h2>
<p id="defaulttext" data-l10n-id="pad.modals.disconnected.cause"></p>
<button id="forcereconnect" class="btn btn-primary" data-l10n-id="pad.modals.forcereconnect"></button>
<% e.end_block(); %>
</div>
<form id="reconnectform" method="post" action="/ep/pad/reconnect" accept-charset="UTF-8" style="display: none;">
<input type="hidden" class="padId" name="padId">
<input type="hidden" class="diagnosticInfo" name="diagnosticInfo">
<input type="hidden" class="missedChanges" name="missedChanges">
</form> </form>
<% e.end_block(); %> <% e.end_block(); %>
</div></div>
<!-------------------------------->
<!-- EMBED POPUP (Share, embed) -->
<!-------------------------------->
<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">
<input type="checkbox" id="readonlyinput">
<label for="readonlyinput" data-l10n-id="pad.share.readonly"></label>
</div>
<div id="linkcode">
<h2 data-l10n-id="pad.share.link"></h2>
<input id="linkinput" type="text" value="" onclick="this.select()">
</div>
<div id="embedcode">
<h2 data-l10n-id="pad.share.emebdcode"></h2>
<input id="embedinput" type="text" value="" onclick="this.select()">
</div>
<% e.end_block(); %>
</div></div>
<div class="sticky-container">
<!---------------------------------------------------------------------->
<!-- USERS POPUP (set username, color, see other users names & color) -->
<!---------------------------------------------------------------------->
<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="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 id="myswatchbox"><div id="myswatch"></div></div>
<div id="myusernameform">
<input type="text" id="myusernameedit" disabled="disabled" data-l10n-id="pad.userlist.entername">
</div>
</div>
<div id="otherusers" aria-role="document">
<table id="otheruserstable" cellspacing="0" cellpadding="0" border="0">
<tr><td></td></tr>
</table>
</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>
<div id="exportColumn">
<div id="chatbox"> <h2 data-l10n-id="pad.importExport.export"></h2>
<div class="chat-content"> <% e.begin_block("exportColumn"); %>
<div id="titlebar"> <a id="exportetherpada" target="_blank" class="exportlink">
<h1 id ="titlelabel" data-l10n-id="pad.chat"></h1> <span class="exporttype buttonicon buttonicon-file-powerpoint" id="exportetherpad" data-l10n-id="pad.importExport.exportetherpad"></span>
<a id="titlecross" class="hide-reduce-btn" onClick="chat.hide();return false;">-&nbsp;</a> </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> <a id="exporthtmla" target="_blank" class="exportlink">
</div> <span class="exporttype buttonicon buttonicon-file-code" id="exporthtml" data-l10n-id="pad.importExport.exporthtml"></span>
<div id="chattext" class="thin-scrollbar" aria-live="polite" aria-relevant="additions removals text" role="log" aria-atomic="false"> </a>
<div alt="loading.." id="chatloadmessagesball" class="chatloadmessages loadingAnimation" align="top"></div> <a id="exportplaina" target="_blank" class="exportlink">
<button id="chatloadmessagesbutton" class="chatloadmessages" data-l10n-id="pad.chat.loadmessages"></button> <span class="exporttype buttonicon buttonicon-file" id="exportplain" data-l10n-id="pad.importExport.exportplain"></span>
</div> </a>
<div id="chatinputbox"> <a id="exportworda" target="_blank" class="exportlink">
<form> <span class="exporttype buttonicon buttonicon-file-word" id="exportword" data-l10n-id="pad.importExport.exportword"></span>
<textarea id="chatinput" maxlength="999" data-l10n-id="pad.chat.writeMessage.placeholder"></textarea> </a>
</form> <a id="exportpdfa" target="_blank" class="exportlink">
</div> <span class="exporttype buttonicon buttonicon-file-pdf" id="exportpdf" data-l10n-id="pad.importExport.exportpdf"></span>
</div> </a>
<a id="exportopena" target="_blank" class="exportlink">
<span class="exporttype buttonicon buttonicon-file-alt" id="exportopen" data-l10n-id="pad.importExport.exportopen"></span>
</a>
<% e.end_block(); %>
</div> </div>
</div> </div>
</div>
<!------------------------------------------------------------------> <!---------------------------------------------------->
<!-- SKIN VARIANTS BUILDER (Customize rendering, only for admins) --> <!-- CONNECTIVITY POPUP (when you get disconnected) -->
<!------------------------------------------------------------------> <!---------------------------------------------------->
<% if (settings.skinName == 'colibris') { %>
<div id="skin-variants" 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>
</div>
<div class="reconnecting">
<h1 data-l10n-id="pad.modals.reconnecting"></h1>
<i class='buttonicon buttonicon-spin5 icon-spin'>
<img src='../static/img/brand.svg' class='etherpadBrand'><br/>
</i>
</div>
<div class="userdup">
<h1 data-l10n-id="pad.modals.userdup"></h1>
<h2 data-l10n-id="pad.modals.userdup.explanation"></h2>
<p id="defaulttext" data-l10n-id="pad.modals.userdup.advice"></p>
<button id="forcereconnect" class="btn btn-primary" data-l10n-id="pad.modals.forcereconnect"></button>
</div>
<div class="unauth">
<h1 data-l10n-id="pad.modals.unauth"></h1>
<p id="defaulttext" data-l10n-id="pad.modals.unauth.explanation"></p>
<button id="forcereconnect" class="btn btn-primary" data-l10n-id="pad.modals.forcereconnect"></button>
</div>
<div class="looping">
<h1 data-l10n-id="pad.modals.disconnected"></h1>
<h2 data-l10n-id="pad.modals.looping.explanation"></h2>
<p data-l10n-id="pad.modals.looping.cause"></p>
</div>
<div class="initsocketfail">
<h1 data-l10n-id="pad.modals.initsocketfail"></h1>
<h2 data-l10n-id="pad.modals.initsocketfail.explanation"></h2>
<p data-l10n-id="pad.modals.initsocketfail.cause"></p>
</div>
<div class="slowcommit with_reconnect_timer">
<h1 data-l10n-id="pad.modals.disconnected"></h1>
<h2 data-l10n-id="pad.modals.slowcommit.explanation"></h2>
<p id="defaulttext" data-l10n-id="pad.modals.slowcommit.cause"></p>
<button id="forcereconnect" class="btn btn-primary" data-l10n-id="pad.modals.forcereconnect"></button>
</div>
<div class="badChangeset with_reconnect_timer">
<h1 data-l10n-id="pad.modals.disconnected"></h1>
<h2 data-l10n-id="pad.modals.badChangeset.explanation"></h2>
<p id="defaulttext" data-l10n-id="pad.modals.badChangeset.cause"></p>
<button id="forcereconnect" class="btn btn-primary" data-l10n-id="pad.modals.forcereconnect"></button>
</div>
<div class="corruptPad">
<h1 data-l10n-id="pad.modals.disconnected"></h1>
<h2 data-l10n-id="pad.modals.corruptPad.explanation"></h2>
<p data-l10n-id="pad.modals.corruptPad.cause"></p>
</div>
<div class="deleted">
<h1 data-l10n-id="pad.modals.deleted"></h1>
<p data-l10n-id="pad.modals.deleted.explanation"></p>
</div>
<div class="rateLimited">
<h1 data-l10n-id="pad.modals.rateLimited"></h1>
<p data-l10n-id="pad.modals.rateLimited.explanation"></p>
</div>
<div class="rejected">
<h1 data-l10n-id="pad.modals.disconnected"></h1>
<h2 data-l10n-id="pad.modals.rejected.explanation"></h2>
<p data-l10n-id="pad.modals.rejected.cause"></p>
</div>
<div class="disconnected with_reconnect_timer">
<% e.begin_block("disconnected"); %>
<h1 data-l10n-id="pad.modals.disconnected"></h1>
<h2 data-l10n-id="pad.modals.disconnected.explanation"></h2>
<p id="defaulttext" data-l10n-id="pad.modals.disconnected.cause"></p>
<button id="forcereconnect" class="btn btn-primary" data-l10n-id="pad.modals.forcereconnect"></button>
<% e.end_block(); %>
</div>
<form id="reconnectform" method="post" action="/ep/pad/reconnect" accept-charset="UTF-8" style="display: none;">
<input type="hidden" class="padId" name="padId">
<input type="hidden" class="diagnosticInfo" name="diagnosticInfo">
<input type="hidden" class="missedChanges" name="missedChanges">
</form>
<% e.end_block(); %>
</div>
</div>
<!-------------------------------->
<!-- EMBED POPUP (Share, embed) -->
<!-------------------------------->
<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">
<input type="checkbox" id="readonlyinput">
<label for="readonlyinput" data-l10n-id="pad.share.readonly"></label>
</div>
<div id="linkcode">
<h2 data-l10n-id="pad.share.link"></h2>
<input id="linkinput" type="text" value="" onclick="this.select()">
</div>
<div id="embedcode">
<h2 data-l10n-id="pad.share.emebdcode"></h2>
<input id="embedinput" type="text" value="" onclick="this.select()">
</div>
<% e.end_block(); %>
</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">
<% e.begin_block("userlist"); %>
<div id="connectionstatus"></div>
<div id="myuser">
<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 id="myswatchbox"><div id="myswatch"></div></div>
<div id="myusernameform">
<input type="text" id="myusernameedit" disabled="disabled" data-l10n-id="pad.userlist.entername">
</div>
</div>
<div id="otherusers" aria-role="document">
<table id="otheruserstable" cellspacing="0" cellpadding="0" border="0">
<tr><td></td></tr>
</table>
</div>
<div id="userlistbuttonarea"></div>
<% e.end_block(); %>
</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">
<h1>Skin Builder</h1> <h1>Skin Builder</h1>
<div class="dropdowns-container"> <div class="dropdowns-container">
<% containers = [ "toolbar", "background", "editor" ]; %> <% containers = [ "toolbar", "background", "editor" ]; %>
<% for(var i=0; i < containers.length; i++) { %> <% for(var i=0; i < containers.length; i++) { %>
<p class="dropdown-line"> <p class="dropdown-line">
<label class="skin-variant-container"><%=containers[i]%></label> <label class="skin-variant-container"><%=containers[i]%></label>
<select class="skin-variant skin-variant-color" data-container="<%=containers[i]%>"> <select class="skin-variant skin-variant-color" data-container="<%=containers[i]%>">
@ -413,28 +386,28 @@
<option value="super-dark">Super Dark</option> <option value="super-dark">Super Dark</option>
</select> </select>
</p> </p>
<% } %> <% } %>
</div> </div>
<p> <p>
<input type="checkbox" id="skin-variant-full-width" class="skin-variant"/> <input type="checkbox" id="skin-variant-full-width" class="skin-variant"/>
<label for="skin-variant-full-width">Full Width Editor</label> <label for="skin-variant-full-width">Full Width Editor</label>
</p> </p>
<p> <p>
<label>Result to copy in settings.json</label> <label>Result to copy in settings.json</label>
<input id="skin-variants-result" type="text" readonly class="disabled" /> <input id="skin-variants-result" type="text" readonly class="disabled" />
</p> </p>
</div></div> </div>
<% } %> </div>
<% } %>
<% e.end_block(); %> <% e.end_block(); %>
</div> <!-- End of #editorcontainerbox --> </div> <!-- End of #editorcontainerbox -->
<% e.end_block(); %> <% e.end_block(); %>
<!-----------------------------> <!----------------------------->
<!-------- JAVASCRIPT ---------> <!-------- JAVASCRIPT --------->
<!-----------------------------> <!----------------------------->
@ -454,53 +427,62 @@
<!-- Bootstrap page --> <!-- Bootstrap page -->
<script type="text/javascript"> <script type="text/javascript">
// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt // @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt
var clientVars = { var clientVars = {
// This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the
// server sends the CLIENT_VARS message. // server sends the CLIENT_VARS message.
randomVersionString: <%-JSON.stringify(settings.randomVersionString)%>, randomVersionString: <%-JSON.stringify(settings.randomVersionString)%>,
}; };
(function () { (function () {
var pathComponents = location.pathname.split('/'); var pathComponents = location.pathname.split('/');
// Strip 'p' and the padname from the pathname and set as baseURL // Strip 'p' and the padname from the pathname and set as baseURL
var baseURL = pathComponents.slice(0,pathComponents.length-2).join('/') + '/'; var baseURL = pathComponents.slice(0,pathComponents.length-2).join('/') + '/';
require.setRootURI(baseURL + "javascripts/src"); require.setRootURI(baseURL + "javascripts/src");
require.setLibraryURI(baseURL + "javascripts/lib"); require.setLibraryURI(baseURL + "javascripts/lib");
require.setGlobalKeyPath("require"); require.setGlobalKeyPath("require");
$ = jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery; // Expose jQuery #HACK $ = jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery; // Expose jQuery #HACK
browser = require('ep_etherpad-lite/static/js/vendors/browser'); browser = require('ep_etherpad-lite/static/js/vendors/browser');
var plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins'); var plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins');
var hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks'); var hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks');
plugins.baseURL = baseURL; plugins.baseURL = baseURL;
plugins.update(function () { plugins.update(function () {
// Mechanism for tests to register hook functions (install fake plugins). // Mechanism for tests to register hook functions (install fake plugins).
window._postPluginUpdateForTestingDone = false; window._postPluginUpdateForTestingDone = false;
if (window._postPluginUpdateForTesting != null) window._postPluginUpdateForTesting(); if (window._postPluginUpdateForTesting != null) window._postPluginUpdateForTesting();
window._postPluginUpdateForTestingDone = true; window._postPluginUpdateForTestingDone = true;
// Call documentReady hook // Call documentReady hook
$(function() { $(function() {
hooks.aCallAll('documentReady'); hooks.aCallAll('documentReady');
});
var pad = require('ep_etherpad-lite/static/js/pad');
pad.baseURL = baseURL;
pad.init();
}); });
/* TODO: These globals shouldn't exist. */ var pad = require('ep_etherpad-lite/static/js/pad');
pad = require('ep_etherpad-lite/static/js/pad').pad; pad.baseURL = baseURL;
chat = require('ep_etherpad-lite/static/js/chat').chat; pad.init();
padeditbar = require('ep_etherpad-lite/static/js/pad_editbar').padeditbar; });
padimpexp = require('ep_etherpad-lite/static/js/pad_impexp').padimpexp;
require('ep_etherpad-lite/static/js/skin_variants');
}()); /* TODO: These globals shouldn't exist. */
// @license-end pad = require('ep_etherpad-lite/static/js/pad').pad;
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');
}());
// @license-end
</script> </script>
<div style="display:none"><a href="/javascript" data-jslicense="1">JavaScript license information</a></div> <div style="display:none"><a href="/javascript" data-jslicense="1">JavaScript license information</a></div>
<% e.end_block(); %> <% e.end_block(); %>

View file

@ -1,6 +1,9 @@
'use strict'; 'use strict';
const assert = require('assert').strict;
const common = require('../../common'); const common = require('../../common');
const plugins = require('../../../../static/js/pluginfw/plugins');
const settings = require('../../../../node/utils/Settings');
let agent; let agent;
const apiKey = common.apiKey; const apiKey = common.apiKey;
@ -12,94 +15,107 @@ const timestamp = Date.now();
const endPoint = (point) => `/api/${apiVersion}/${point}?apikey=${apiKey}`; const endPoint = (point) => `/api/${apiVersion}/${point}?apikey=${apiKey}`;
describe(__filename, function () { describe(__filename, function () {
before(async function () { agent = await common.init(); }); const backups = {settings: {}};
describe('API Versioning', function () { before(async function () {
it('errors if can not connect', function (done) { backups.settings.integratedChat = settings.integratedChat;
agent.get('/api/') settings.integratedChat = true;
.expect((res) => { await plugins.update();
apiVersion = res.body.currentVersion; agent = await common.init();
if (!res.body.currentVersion) throw new Error('No version set in API'); await agent.get('/api/')
return; .expect(200)
}) .expect((res) => {
.expect(200, done); assert(res.body.currentVersion);
}); apiVersion = res.body.currentVersion;
});
await agent.get(`${endPoint('createPad')}&padID=${padID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
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
});
}); });
// BEGIN GROUP AND AUTHOR TESTS after(async function () {
// /////////////////////////////////// Object.assign(settings, backups.settings);
// /////////////////////////////////// await plugins.update();
/* 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');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
}); });
describe('createAuthor', function () { describe('settings.integratedChat = true', function () {
it('Creates an author with a name set', function (done) { beforeEach(async function () {
agent.get(endPoint('createAuthor')) settings.integratedChat = true;
.expect((res) => {
if (res.body.code !== 0 || !res.body.data.authorID) {
throw new Error('Unable to create author');
}
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('appendChatMessage', async function () {
it('Adds a chat message to the pad', function (done) { await agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` +
agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` +
`&authorID=${authorID}&time=${timestamp}`) `&authorID=${authorID}&time=${timestamp}`)
.expect((res) => { .expect(200)
if (res.body.code !== 0) throw new Error('Unable to create chat message');
})
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200, done); .expect((res) => {
assert.equal(res.body.code, 0);
});
});
it('getChatHead', async function () {
await agent.get(`${endPoint('getChatHead')}&padID=${padID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
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((res) => {
assert.equal(res.body.code, 0);
assert.equal(res.body.data.messages.length, 1);
});
}); });
}); });
describe('settings.integratedChat = false', function () {
describe('getChatHead', function () { beforeEach(async function () {
it('Gets the head of chat', function (done) { settings.integratedChat = false;
agent.get(`${endPoint('getChatHead')}&padID=${padID}`)
.expect((res) => {
if (res.body.data.chatHead !== 0) throw new Error('Chat Head Length is wrong');
if (res.body.code !== 0) throw new Error('Unable to get chat head');
})
.expect('Content-Type', /json/)
.expect(200, done);
}); });
});
describe('getChatHistory', function () { it('appendChatMessage returns an error', async function () {
it('Gets Chat History of a Pad', function (done) { await agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` +
agent.get(`${endPoint('getChatHistory')}&padID=${padID}`) `&authorID=${authorID}&time=${timestamp}`)
.expect((res) => { .expect(500)
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');
})
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200, done); .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'; 'use strict';
/** /**
* caching_middleware is responsible for serving everything under path `/javascripts/` * caching_middleware is responsible for serving everything under path `/javascripts/`. That
* That includes packages as defined in `src/node/utils/tar.json` and probably also plugin code * includes packages as defined in `src/node/hooks/express/static.js` and probably also plugin code.
*
*/ */
const common = require('../common'); const common = require('../common');

View file

@ -6,155 +6,185 @@ const assert = require('assert').strict;
const common = require('../common'); const common = require('../common');
const padManager = require('../../../node/db/PadManager'); const padManager = require('../../../node/db/PadManager');
const pluginDefs = require('../../../static/js/pluginfw/plugin_defs'); 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 logger = common.logger;
const checkHook = async (hookName, checkFn) => { const checkHook = async (hookName, checkFn) => {
if (pluginDefs.hooks[hookName] == null) pluginDefs.hooks[hookName] = []; if (pluginDefs.hooks[hookName] == null) pluginDefs.hooks[hookName] = [];
await new Promise((resolve, reject) => { let hook;
pluginDefs.hooks[hookName].push({ try {
hook_fn: async (hookName, context) => { await new Promise((resolve, reject) => {
if (checkFn == null) return; hook = {
logger.debug(`hook ${hookName} invoked`); hook_fn: async (hookName, context) => {
try { if (checkFn == null) return;
// Make sure checkFn is called only once. logger.debug(`hook ${hookName} invoked`);
const _checkFn = checkFn; try {
checkFn = null; // Make sure checkFn is called only once.
await _checkFn(context); const _checkFn = checkFn;
} catch (err) { checkFn = null;
reject(err); await _checkFn(context);
return; } catch (err) {
} reject(err);
resolve(); return;
}, }
resolve();
},
};
pluginDefs.hooks[hookName].push(hook);
}); });
}); } finally {
pluginDefs.hooks[hookName] = pluginDefs.hooks[hookName].filter((h) => h !== hook);
}
}; };
const sendMessage = (socket, data) => { const sendMessage = async (socket, data) => (
socket.send({ await common.sendMessage(socket, {type: 'COLLABROOM', component: 'pad', data}));
type: 'COLLABROOM', const sendChat = async (socket, message) => (
component: 'pad', await sendMessage(socket, {type: 'CHAT_MESSAGE', message}));
data,
});
};
const sendChat = (socket, message) => sendMessage(socket, {type: 'CHAT_MESSAGE', message});
describe(__filename, function () { describe(__filename, function () {
const backups = {settings: {}};
let clientVars;
const padId = 'testChatPad'; const padId = 'testChatPad';
const hooksBackup = {}; let socket;
const connect = async () => {
socket = await common.connect();
({data: clientVars} = await common.handshake(socket, padId));
};
before(async function () { before(async function () {
for (const [name, defs] of Object.entries(pluginDefs.hooks)) { backups.settings.integratedChat = settings.integratedChat;
if (defs == null) continue;
hooksBackup[name] = defs;
}
}); });
beforeEach(async function () { 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)) { if (await padManager.doesPadExist(padId)) {
const pad = await padManager.getPad(padId); const pad = await padManager.getPad(padId);
await pad.remove(); await pad.remove();
} }
}); });
after(async function () { afterEach(async function () {
Object.assign(pluginDefs.hooks, hooksBackup); if (socket) {
for (const name of Object.keys(pluginDefs.hooks)) { socket.close();
if (hooksBackup[name] == null) delete pluginDefs.hooks[name]; socket = null;
} }
}); });
describe('chatNewMessage hook', function () { after(async function () {
let authorId; Object.assign(settings, backups.settings);
let socket; await plugins.update();
});
describe('settings.integratedChat = true', function () {
before(async function () {
settings.integratedChat = true;
await plugins.update();
});
beforeEach(async function () { beforeEach(async function () {
socket = await common.connect(); await connect();
const {data: clientVars} = await common.handshake(socket, padId);
authorId = clientVars.userId;
}); });
afterEach(async function () { describe('chatNewMessage hook', function () {
socket.close(); it('message', async function () {
}); const start = Date.now();
await Promise.all([
it('message', async function () { checkHook('chatNewMessage', ({message}) => {
const start = Date.now(); assert(message != null);
await Promise.all([ assert(message instanceof ChatMessage);
checkHook('chatNewMessage', ({message}) => { assert.equal(message.authorId, clientVars.userId);
assert(message != null); assert.equal(message.text, this.test.title);
assert(message instanceof ChatMessage); assert(message.time >= start);
assert.equal(message.authorId, authorId); assert(message.time <= Date.now());
assert.equal(message.text, this.test.title); }),
assert(message.time >= start); sendChat(socket, {text: this.test.title}),
assert(message.time <= Date.now()); ]);
}),
sendChat(socket, {text: this.test.title}),
]);
});
it('pad', async function () {
await Promise.all([
checkHook('chatNewMessage', ({pad}) => {
assert(pad != null);
assert(pad instanceof Pad);
assert.equal(pad.id, padId);
}),
sendChat(socket, {text: this.test.title}),
]);
});
it('padId', async function () {
await Promise.all([
checkHook('chatNewMessage', (context) => {
assert.equal(context.padId, padId);
}),
sendChat(socket, {text: this.test.title}),
]);
});
it('mutations propagate', async function () {
const listen = async (type) => await new Promise((resolve) => {
const handler = (msg) => {
if (msg.type !== 'COLLABROOM') return;
if (msg.data == null || msg.data.type !== type) return;
resolve(msg.data);
socket.off('message', handler);
};
socket.on('message', handler);
}); });
const modifiedText = `${this.test.title} <added changes>`; it('pad', async function () {
const customMetadata = {foo: this.test.title}; await Promise.all([
await Promise.all([ checkHook('chatNewMessage', ({pad}) => {
checkHook('chatNewMessage', ({message}) => { assert(pad != null);
message.text = modifiedText; assert(pad instanceof Pad);
message.customMetadata = customMetadata; assert.equal(pad.id, padId);
}), }),
(async () => { sendChat(socket, {text: this.test.title}),
const {message} = await listen('CHAT_MESSAGE'); ]);
assert(message != null); });
assert.equal(message.text, modifiedText);
assert.deepEqual(message.customMetadata, customMetadata); it('padId', async function () {
})(), await Promise.all([
sendChat(socket, {text: this.test.title}), checkHook('chatNewMessage', (context) => {
]); assert.equal(context.padId, padId);
// Simulate fetch of historical chat messages when a pad is first loaded. }),
await Promise.all([ sendChat(socket, {text: this.test.title}),
(async () => { ]);
const {messages: [message]} = await listen('CHAT_MESSAGES'); });
assert(message != null);
assert.equal(message.text, modifiedText); it('mutations propagate', async function () {
assert.deepEqual(message.customMetadata, customMetadata); const listen = async (type) => await new Promise((resolve) => {
})(), const handler = (msg) => {
sendMessage(socket, {type: 'GET_CHAT_MESSAGES', start: 0, end: 0}), if (msg.type !== 'COLLABROOM') return;
]); if (msg.data == null || msg.data.type !== type) return;
resolve(msg.data);
socket.off('message', handler);
};
socket.on('message', handler);
});
const modifiedText = `${this.test.title} <added changes>`;
const customMetadata = {foo: this.test.title};
await Promise.all([
checkHook('chatNewMessage', ({message}) => {
message.text = modifiedText;
message.customMetadata = customMetadata;
}),
(async () => {
const {message} = await listen('CHAT_MESSAGE');
assert(message != null);
assert.equal(message.text, modifiedText);
assert.deepEqual(message.customMetadata, customMetadata);
})(),
sendChat(socket, {text: this.test.title}),
]);
// Simulate fetch of historical chat messages when a pad is first loaded.
await Promise.all([
(async () => {
const {messages: [message]} = await listen('CHAT_MESSAGES');
assert(message != null);
assert.equal(message.text, modifiedText);
assert.deepEqual(message.customMetadata, customMetadata);
})(),
sendMessage(socket, {type: 'GET_CHAT_MESSAGES', start: 0, end: 0}),
]);
});
});
});
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($colorPickerPreview.css('background-color')).to.be(testColorRGB);
expect($userSwatch.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.toggleUserList();
await helper.waitForPromise(() => helper.usernameField().val() === '😃'); 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. // chat should be visible.
expect(chaticon.is(':visible')).to.be(true); 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;
});
});
}); });