Compare commits

...

38 Commits

Author SHA1 Message Date
Richard Hansen dda284cbe9 chat: New option to completely disable chat 2022-05-06 20:44:00 -04:00
Richard Hansen 310a371234 chat: Move `.etherpad` import/export handling to `chat.js` 2022-05-06 20:44:00 -04:00
Richard Hansen bc06ef87bb chat: Move HTTP API handlers to `chat.js` 2022-05-06 20:44:00 -04:00
Richard Hansen b6bcc092ed chat: Move message deletion to `padRemove` hook 2022-05-06 20:44:00 -04:00
Richard Hansen bd621043ff chat: Move message copy to `padCopy` hook 2022-05-06 20:44:00 -04:00
Richard Hansen 01f1a8b75e chat: Move `chatHead` initialization to `padLoad` hook 2022-05-06 20:44:00 -04:00
Richard Hansen 42c22ce28d chat: Move message checking to `padCheck` hook 2022-05-06 20:44:00 -04:00
Richard Hansen cdea600acc chat: Move Pad chat methods to `chat.js` 2022-05-06 20:44:00 -04:00
Richard Hansen 2d3418caf7 chat: Deprecate the `chat` export from the client-side chat module 2022-05-06 20:43:55 -04:00
Richard Hansen 2e684476cb chat: Move `chatHead` client var to `clientVars` hook 2022-05-06 20:43:09 -04:00
Richard Hansen 8d5fdd7dc9 chat: Move chat message handling to `handleMessage` hook 2022-05-06 20:42:18 -04:00
Richard Hansen a177a622c4 chat: Move chat-specific pad HTML to `eejsBlock_*` hook functions 2022-05-06 20:41:23 -04:00
Richard Hansen e0f28cfe86 chat: Move cookie pref handling to chat init in `chat.js`
It's unclear why this logic was previously triggered by connection
state changes, but it doesn't make sense for it to be there.
2022-05-06 20:41:13 -04:00
Richard Hansen a5c729af32 chat: Move chat init to `chat.js` 2022-05-06 02:54:34 -04:00
Richard Hansen 8d3a6e5ef9 chat: Move chat message handling to `chat.js` 2022-05-06 02:54:34 -04:00
Richard Hansen 870191f622 chat: Move Alt-C handling to `chat.js` 2022-05-06 02:54:34 -04:00
Richard Hansen 92cd1feb8a chat: Fix blur and focus in Alt-C shortcut handler 2022-05-06 02:54:34 -04:00
Richard Hansen 59848efc9b chat: Delete non-functional Alt-C shortcut handler 2022-05-06 02:54:34 -04:00
Richard Hansen a1388b7876 chat: Move chat-specific option processing to `chat.js` 2022-05-06 02:54:34 -04:00
Richard Hansen 1cbba4ea3a chat: New `hide` method to completely hide chat 2022-05-06 02:54:34 -04:00
Richard Hansen 8ae5f25075 chat: Make sure the icon is visible when minimizing 2022-05-06 02:54:34 -04:00
Richard Hansen b830ed9744 chat: Rename `hide` method to `reduce`
It doesn't hide: If sticky, it unsticks. Otherwise, it minimizes.
2022-05-06 02:54:34 -04:00
Richard Hansen f7d7d89874 chat: Move click handlers to `chat.js` 2022-05-06 02:54:34 -04:00
Richard Hansen 7b7b085a1c tests: chat API: Move test setup to `before()` 2022-05-06 02:54:34 -04:00
Richard Hansen 92d70e5ead tests: chat API: Delete unnecessary `describe()` calls 2022-05-06 02:54:34 -04:00
Richard Hansen cb02253e62 tests: chat API: Reorder assertions
* There's no point in asserting that the Content-Type type is JSON
    if it didn't return 200.
  * There's no point in asserting JSON body properties if the
    Content-Type isn't JSON.
  * There's no point in asserting data properties if it returned an
    error code.
2022-05-06 02:54:34 -04:00
Richard Hansen 3146d6758b tests: chat API: Use `assert` library 2022-05-06 02:54:34 -04:00
Richard Hansen 5603e6b94e tests: chat API: Promisify 2022-05-06 02:54:34 -04:00
Richard Hansen 33c46cb002 tests: chat: Wait for message ack 2022-05-06 02:54:34 -04:00
Richard Hansen 8d062b254e tests: chat: Consolidate all frontend chat tests 2022-05-06 02:54:34 -04:00
Richard Hansen 16f84ba3d3 plugins: Move plugin logging to `plugins.update()` 2022-05-06 02:54:34 -04:00
Richard Hansen 595697bc8a plugins: Don't tell clients about server-side hooks 2022-05-06 02:54:34 -04:00
Richard Hansen 5fe5a87c85 collab_client: Pass raw message to `handleClientMessage_*` hooks 2022-05-06 02:54:34 -04:00
Richard Hansen 9b7108d730 PadMessageHandler: Delete unnecessary variables
to improve readability.
2022-05-06 02:54:34 -04:00
Richard Hansen fec1bc0d4a css: Fix class name typo 2022-05-06 02:54:34 -04:00
Richard Hansen cae949afc6 lint: Fix indentation and formatting in `pad.html` 2022-05-06 02:54:34 -04:00
Richard Hansen 7e2472cef7 Move `tar.json` into JavaScript
This makes it possible to add comments and conditional logic.
2022-05-06 02:54:34 -04:00
Richard Hansen f812e53dc9 API: Add missing `await`
This isn't strictly necessary, but it improves readability.
2022-05-06 02:54:34 -04:00
32 changed files with 1339 additions and 1083 deletions

View File

@ -2,6 +2,8 @@
### Notable enhancements and fixes
* New `integratedChat` setting makes it possible to completely disable the
built-in chat feature (not just hide it).
* Improvements to login session management:
* `express_sid` cookies and `sessionstorage:*` database records are no longer
created unless `requireAuthentication` is `true` (or a plugin causes them to
@ -55,6 +57,8 @@
* New APIs for processing attributes: `ep_etherpad-lite/static/js/attributes`
(low-level API) and `ep_etherpad-lite/static/js/AttributeMap` (high-level
API).
* The `handleClientMessage_${name}` client-side hooks are now passed the raw
message object in the new `msg` context property.
* The `import` server-side hook has a new `ImportError` context property.
* New `exportEtherpad` and `importEtherpad` server-side hooks.
* The `handleMessageSecurity` and `handleMessage` server-side hooks have a new
@ -89,6 +93,18 @@
instead.
* `padUpdate`: The `author` context property is deprecated; use the new
`authorId` context property instead. Also, the hook now runs asynchronously.
* Chat API deprecations and removals (no replacements planned):
* Server-side:
* The `Pad.appendChatMessage()` method is deprecated.
* The `Pad.getChatMessage()` method is deprecated.
* The `Pad.getChatMessages()` method is deprecated.
* The `sendChatMessageToPadClients()` function in
`src/node/handler/PadMessageHandler.js` is deprecated.
* Client-side:
* The `chat` global variable is deprecated.
* The `chat` export in `src/static/js/chat.js` is deprecated.
* The `pad.determineChatVisibility()` method was removed.
* The `pad.determineChatAndUsersVisibility()` method was removed.
* Returning `true` from a `handleMessageSecurity` hook function is deprecated;
return `'permitOnce'` instead.
* Changes to the `src/static/js/Changeset.js` library:

View File

@ -393,15 +393,10 @@ This hook is called after the content of a node is collected by the usual
methods. The cc object can be used to do a bunch of things that modify the
content of the pad. See, for example, the heading1 plugin for etherpad original.
## handleClientMessage_`name`
## `handleClientMessage_${name}`
Called from: `src/static/js/collab_client.js`
Things in context:
1. payload - the data that got sent with the message (use it for custom message
content)
This hook gets called every time the client receives a message of type `name`.
This can most notably be used with the new HTTP API call, "sendClientsMessage",
which sends a custom message type to all clients connected to a pad. You can
@ -410,6 +405,12 @@ also use this to handle existing types.
`collab_client.js` has a pretty extensive list of message types, if you want to
take a look.
Context properties:
* `msg`: The raw message object.
* `payload`: The data that got sent with the message. Usually this is
`msg.payload`.
## aceStartLineAndCharForPoint-aceEndLineAndCharForPoint
Called from: src/static/js/ace2_inner.js

View File

@ -1086,7 +1086,7 @@ exports.userLeave = async (hookName, {author, padId}) => {
## `chatNewMessage`
Called from: `src/node/handler/PadMessageHandler.js`
Called from: `src/node/chat.js`
Called when a user (or plugin) generates a new chat message, just before it is
saved to the pad and relayed to all connected users.

View File

@ -80,15 +80,16 @@ The `settings.json.docker` available by default allows to control almost every s
### General
| Variable | Description | Default |
| ------------------ | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `TITLE` | The name of the instance | `Etherpad` |
| `FAVICON` | favicon default name, or a fully specified URL to your own favicon | `favicon.ico` |
| `DEFAULT_PAD_TEXT` | The default text of a pad | `Welcome to Etherpad! This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents! Get involved with Etherpad at https://etherpad.org` |
| `IP` | IP which etherpad should bind at. Change to `::` for IPv6 | `0.0.0.0` |
| `PORT` | port which etherpad should bind at | `9001` |
| `ADMIN_PASSWORD` | the password for the `admin` user (leave unspecified if you do not want to create it) | |
| `USER_PASSWORD` | the password for the first user `user` (leave unspecified if you do not want to create it) | |
| Variable | Description | Default |
|--------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `TITLE` | The name of the instance | `Etherpad` |
| `FAVICON` | favicon default name, or a fully specified URL to your own favicon | `favicon.ico` |
| `DEFAULT_PAD_TEXT` | The default text of a pad | `Welcome to Etherpad! This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents! Get involved with Etherpad at https://etherpad.org` |
| `INTEGRATED_CHAT` | Whether to enable the built-in chat feature. Set this to false if you prefer to use a plugin to provide chat functionality or simply do not want the feature. | true |
| `IP` | IP which etherpad should bind at. Change to `::` for IPv6 | `0.0.0.0` |
| `PORT` | port which etherpad should bind at | `9001` |
| `ADMIN_PASSWORD` | the password for the `admin` user (leave unspecified if you do not want to create it) | |
| `USER_PASSWORD` | the password for the first user `user` (leave unspecified if you do not want to create it) | |
### Database

View File

@ -223,6 +223,13 @@
*/
"defaultPadText" : "${DEFAULT_PAD_TEXT:Welcome to Etherpad!\n\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\n\nGet involved with Etherpad at https:\/\/etherpad.org\n}",
/*
* Whether to enable the built-in chat feature. Set this to false if you
* prefer to use a plugin to provide chat functionality or simply do not want
* the feature.
*/
"integratedChat": "${INTEGRATED_CHAT:true}",
/*
* Default Pad behavior.
*
@ -231,6 +238,7 @@
"padOptions": {
"noColors": "${PAD_OPTIONS_NO_COLORS:false}",
"showControls": "${PAD_OPTIONS_SHOW_CONTROLS:true}",
// To completely disable chat, set integratedChat to false.
"showChat": "${PAD_OPTIONS_SHOW_CHAT:true}",
"showLineNumbers": "${PAD_OPTIONS_SHOW_LINE_NUMBERS:true}",
"useMonospaceFont": "${PAD_OPTIONS_USE_MONOSPACE_FONT:false}",

View File

@ -224,6 +224,13 @@
*/
"defaultPadText" : "Welcome to Etherpad!\n\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\n\nGet involved with Etherpad at https:\/\/etherpad.org\n",
/*
* Whether to enable the built-in chat feature. Set this to false if you
* prefer to use a plugin to provide chat functionality or simply do not want
* the feature.
*/
"integratedChat": true,
/*
* Default Pad behavior.
*
@ -232,6 +239,7 @@
"padOptions": {
"noColors": false,
"showControls": true,
// To completely disable chat, set integratedChat to false.
"showChat": true,
"showLineNumbers": true,
"useMonospaceFont": false,

View File

@ -12,6 +12,33 @@
"shutdown": "ep_etherpad-lite/node/utils/Minify"
}
},
{
"name": "chat",
"client_hooks": {
"aceKeyEvent": "ep_etherpad-lite/static/js/chat",
"handleClientMessage_CHAT_MESSAGE": "ep_etherpad-lite/static/js/chat",
"handleClientMessage_CHAT_MESSAGES": "ep_etherpad-lite/static/js/chat",
"postAceInit": "ep_etherpad-lite/static/js/chat"
},
"hooks": {
"clientVars": "ep_etherpad-lite/node/chat",
"eejsBlock_mySettings": "ep_etherpad-lite/node/chat",
"eejsBlock_stickyContainer": "ep_etherpad-lite/node/chat",
"exportEtherpad": "ep_etherpad-lite/node/chat",
"handleMessage": "ep_etherpad-lite/node/chat",
"importEtherpad": "ep_etherpad-lite/node/chat",
"padCheck": "ep_etherpad-lite/node/chat",
"padCopy": "ep_etherpad-lite/node/chat",
"padLoad": "ep_etherpad-lite/node/chat",
"padRemove": "ep_etherpad-lite/node/chat"
}
},
{
"name": "chatAlwaysLoaded",
"hooks": {
"socketio": "ep_etherpad-lite/node/chat"
}
},
{
"name": "express",
"hooks": {

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

@ -0,0 +1,324 @@
'use strict';
const ChatMessage = require('../static/js/ChatMessage');
const CustomError = require('./utils/customError');
const Stream = require('./utils/Stream');
const api = require('./db/API');
const assert = require('assert').strict;
const authorManager = require('./db/AuthorManager');
const hooks = require('../static/js/pluginfw/hooks.js');
const pad = require('./db/Pad');
const padManager = require('./db/PadManager');
const padMessageHandler = require('./handler/PadMessageHandler');
const settings = require('./utils/Settings');
let socketio;
const appendChatMessage = async (pad, msg) => {
if (!settings.integratedChat) {
throw new Error('integrated chat is disabled (see integratedChat in settings.json)');
}
pad.chatHead++;
await Promise.all([
// Don't save the display name in the database because the user can change it at any time. The
// `displayName` property will be populated with the current value when the message is read from
// the database.
pad.db.set(`pad:${pad.id}:chat:${pad.chatHead}`, {...msg, displayName: undefined}),
pad.saveToDatabase(),
]);
};
const getChatMessage = async (pad, entryNum) => {
if (!settings.integratedChat) {
throw new Error('integrated chat is disabled (see integratedChat in settings.json)');
}
const entry = await pad.db.get(`pad:${pad.id}:chat:${entryNum}`);
if (entry == null) return null;
const message = ChatMessage.fromObject(entry);
message.displayName = await authorManager.getAuthorName(message.authorId);
return message;
};
const getChatMessages = async (pad, start, end) => {
if (!settings.integratedChat) {
throw new Error('integrated chat is disabled (see integratedChat in settings.json)');
}
const entries = await Promise.all(
[...Array(end + 1 - start).keys()].map((i) => getChatMessage(pad, start + i)));
// sort out broken chat entries
// it looks like in happened in the past that the chat head was
// incremented, but the chat message wasn't added
return entries.filter((entry) => {
const pass = (entry != null);
if (!pass) {
console.warn(`WARNING: Found broken chat entry in pad ${pad.id}`);
}
return pass;
});
};
const sendChatMessageToPadClients = async (message, padId) => {
if (!settings.integratedChat) {
throw new Error('integrated chat is disabled (see integratedChat in settings.json)');
}
const pad = await padManager.getPad(padId, null, message.authorId);
await hooks.aCallAll('chatNewMessage', {message, pad, padId});
// appendChatMessage() ignores the displayName property so we don't need to wait for
// authorManager.getAuthorName() to resolve before saving the message to the database.
const promise = appendChatMessage(pad, message);
message.displayName = await authorManager.getAuthorName(message.authorId);
socketio.sockets.in(padId).json.send({
type: 'COLLABROOM',
data: {type: 'CHAT_MESSAGE', message},
});
await promise;
};
exports.clientVars = (hookName, {pad: {chatHead}}) => ({chatHead});
exports.eejsBlock_mySettings = (hookName, context) => {
if (!settings.integratedChat) return;
context.content += `
<p class="hide-for-mobile">
<input type="checkbox" id="options-stickychat">
<label for="options-stickychat" data-l10n-id="pad.settings.stickychat"></label>
</p>
<p class="hide-for-mobile">
<input type="checkbox" id="options-chatandusers">
<label for="options-chatandusers" data-l10n-id="pad.settings.chatandusers"></label>
</p>
`;
};
exports.eejsBlock_stickyContainer = (hookName, context) => {
if (!settings.integratedChat) return;
/* eslint-disable max-len */
context.content += `
<div id="chaticon" class="visible" title="Chat (Alt C)">
<span id="chatlabel" data-l10n-id="pad.chat"></span>
<span class="buttonicon buttonicon-chat"></span>
<span id="chatcounter">0</span>
</div>
<div id="chatbox">
<div class="chat-content">
<div id="titlebar">
<h1 id ="titlelabel" data-l10n-id="pad.chat"></h1>
<a id="titlecross" class="hide-reduce-btn">-&nbsp;</a>
<a id="titlesticky" class="stick-to-screen-btn" data-l10n-id="pad.chat.stick.title">&nbsp;&nbsp;</a>
</div>
<div id="chattext" class="thin-scrollbar" aria-live="polite" aria-relevant="additions removals text" role="log" aria-atomic="false">
<div alt="loading.." id="chatloadmessagesball" class="chatloadmessages loadingAnimation" align="top"></div>
<button id="chatloadmessagesbutton" class="chatloadmessages" data-l10n-id="pad.chat.loadmessages"></button>
</div>
<div id="chatinputbox">
<form>
<textarea id="chatinput" maxlength="999" data-l10n-id="pad.chat.writeMessage.placeholder"></textarea>
</form>
</div>
</div>
</div>
`;
/* eslint-enable max-len */
};
exports.exportEtherpad = async (hookName, {pad, data, dstPadId}) => {
const ops = (function* () {
const {chatHead = -1} = pad;
data[`pad:${dstPadId}`].chatHead = chatHead;
for (let i = 0; i <= chatHead; ++i) {
yield (async () => {
const v = await pad.db.get(`pad:${pad.id}:chat:${i}`);
if (v == null) return;
data[`pad:${dstPadId}:chat:${i}`] = v;
})();
}
})();
for (const op of new Stream(ops).batch(100).buffer(99)) await op;
};
exports.handleMessage = async (hookName, {message, sessionInfo, socket}) => {
if (!settings.integratedChat) return;
const {authorId, padId, readOnly} = sessionInfo;
if (message.type !== 'COLLABROOM' || readOnly) return;
switch (message.data.type) {
case 'CHAT_MESSAGE': {
const chatMessage = ChatMessage.fromObject(message.data.message);
// Don't trust the user-supplied values.
chatMessage.time = Date.now();
chatMessage.authorId = authorId;
await sendChatMessageToPadClients(chatMessage, padId);
break;
}
case 'GET_CHAT_MESSAGES': {
const {start, end} = message.data;
if (!Number.isInteger(start)) throw new Error(`missing or invalid start: ${start}`);
if (!Number.isInteger(end)) throw new Error(`missing or invalid end: ${end}`);
const count = end - start;
if (count < 0 || count > 100) throw new Error(`invalid number of messages: ${count}`);
const pad = await padManager.getPad(padId, null, authorId);
socket.json.send({
type: 'COLLABROOM',
data: {
type: 'CHAT_MESSAGES',
messages: await getChatMessages(pad, start, end),
},
});
break;
}
default:
return;
}
return null; // Important! Returning null (not undefined!) stops further processing.
};
exports.importEtherpad = async (hookName, {pad, data, srcPadId}) => {
const ops = (function* () {
const {chatHead = -1} = data[`pad:${srcPadId}`];
pad.chatHead = chatHead;
for (let i = 0; i <= chatHead; ++i) {
const v = data[`pad:${srcPadId}:chat:${i}`];
if (v == null) continue;
yield pad.db.set(`pad:${pad.id}:chat:${i}`, v);
}
})();
for (const op of new Stream(ops).batch(100).buffer(99)) await op;
};
exports.padCheck = async (hookName, {pad}) => {
assert(pad.chatHead != null);
assert(Number.isInteger(pad.chatHead));
assert(pad.chatHead >= -1);
const chats = Stream.range(0, pad.chatHead).map(async (c) => {
try {
const msg = await getChatMessage(pad, c);
assert(msg != null);
assert(msg instanceof ChatMessage);
} catch (err) {
err.message = `(pad ${pad.id} chat message ${c}) ${err.message}`;
throw err;
}
});
for (const p of chats.batch(100).buffer(99)) await p;
};
exports.padCopy = async (hookName, {srcPad, dstPad}) => {
const {chatHead = -1} = srcPad;
dstPad.chatHead = chatHead;
const copyChat = async (i) => {
const val = await srcPad.db.get(`pad:${srcPad.id}:chat:${i}`);
await dstPad.db.set(`pad:${dstPad.id}:chat:${i}`, val);
};
const ops = (function* () {
for (let i = 0; i <= chatHead; ++i) yield copyChat(i);
})();
for (const op of new Stream(ops).batch(100).buffer(99)) await op;
};
exports.padLoad = async (hookName, {pad}) => {
if (!('chatHead' in pad)) pad.chatHead = -1;
};
exports.padRemove = async (hookName, {pad}) => {
const ops = (function* () {
const {chatHead = -1} = pad;
for (let i = 0; i <= chatHead; ++i) yield pad.db.remove(`pad:${pad.id}:chat:${i}`);
})();
for (const op of new Stream(ops).batch(100).buffer(99)) await op;
};
exports.socketio = (hookName, {io}) => {
socketio = io;
};
const getPadSafe = async (padId) => {
if (typeof padId !== 'string') throw new CustomError('padID is not a string', 'apierror');
if (!padManager.isValidPadId(padId)) throw new CustomError('padID is not valid', 'apierror');
if (!await padManager.doesPadExist(padId)) throw new CustomError('pad not found', 'apierror');
return await padManager.getPad(padId);
};
api.registerChatHandlers({
/**
* appendChatMessage(padId, text, authorId, time), creates a chat message for the pad id,
* time is a timestamp
*
* Example returns:
*
* {code: 0, message:"ok", data: null}
* {code: 1, message:"padID does not exist", data: null}
*/
appendChatMessage: async (padId, text, authorId, time) => {
if (!settings.integratedChat) {
throw new Error('integrated chat is disabled (see integratedChat in settings.json)');
}
if (typeof text !== 'string') throw new CustomError('text is not a string', 'apierror');
if (time === undefined || !Number.isInteger(Number.parseFloat(time))) time = Date.now();
await sendChatMessageToPadClients(new ChatMessage(text, authorId, time), padId);
},
/**
* getChatHead(padId) returns the chatHead (last number of the last chat-message) of the pad
*
* Example returns:
*
* {code: 0, message:"ok", data: {chatHead: 42}}
* {code: 1, message:"padID does not exist", data: null}
*/
getChatHead: async (padId) => {
if (!settings.integratedChat) {
throw new Error('integrated chat is disabled (see integratedChat in settings.json)');
}
const pad = await getPadSafe(padId);
const {chatHead = -1} = pad;
return {chatHead};
},
/**
* getChatHistory(padId, start, end), returns a part of or the whole chat-history of this pad
*
* Example returns:
*
* {"code":0,"message":"ok","data":{"messages":[
* {"text":"foo","authorID":"a.foo","time":1359199533759,"userName":"test"},
* {"text":"bar","authorID":"a.foo","time":1359199534622,"userName":"test"}
* ]}}
*
* {code: 1, message:"start is higher or equal to the current chatHead", data: null}
*
* {code: 1, message:"padID does not exist", data: null}
*/
getChatHistory: async (padId, start, end) => {
if (!settings.integratedChat) {
throw new Error('integrated chat is disabled (see integratedChat in settings.json)');
}
if (start && end) {
if (start < 0) throw new CustomError('start is below zero', 'apierror');
if (end < 0) throw new CustomError('end is below zero', 'apierror');
if (start > end) throw new CustomError('start is higher than end', 'apierror');
}
const pad = await getPadSafe(padId);
const {chatHead = -1} = pad;
if (!start || !end) {
start = 0;
end = chatHead;
}
if (start > chatHead) {
throw new CustomError('start is higher than the current chatHead', 'apierror');
}
if (end > chatHead) {
throw new CustomError('end is higher than the current chatHead', 'apierror');
}
return {messages: await getChatMessages(pad, start, end)};
},
});
pad.registerLegacyChatMethodHandlers({
appendChatMessage,
getChatMessage,
getChatMessages,
});
padMessageHandler.registerLegacyChatHandlers({
sendChatMessageToPadClients,
});

View File

@ -20,7 +20,6 @@
*/
const Changeset = require('../../static/js/Changeset');
const ChatMessage = require('../../static/js/ChatMessage');
const CustomError = require('../utils/customError');
const padManager = require('./PadManager');
const padMessageHandler = require('../handler/PadMessageHandler');
@ -289,82 +288,7 @@ exports.setHTML = async (padID, html, authorId = '') => {
* CHAT FUNCTIONS *
**************** */
/**
getChatHistory(padId, start, end), returns a part of or the whole chat-history of this pad
Example returns:
{"code":0,"message":"ok","data":{"messages":[
{"text":"foo","authorID":"a.foo","time":1359199533759,"userName":"test"},
{"text":"bar","authorID":"a.foo","time":1359199534622,"userName":"test"}
]}}
{code: 1, message:"start is higher or equal to the current chatHead", data: null}
{code: 1, message:"padID does not exist", data: null}
*/
exports.getChatHistory = async (padID, start, end) => {
if (start && end) {
if (start < 0) {
throw new CustomError('start is below zero', 'apierror');
}
if (end < 0) {
throw new CustomError('end is below zero', 'apierror');
}
if (start > end) {
throw new CustomError('start is higher than end', 'apierror');
}
}
// get the pad
const pad = await getPadSafe(padID, true);
const chatHead = pad.chatHead;
// fall back to getting the whole chat-history if a parameter is missing
if (!start || !end) {
start = 0;
end = pad.chatHead;
}
if (start > chatHead) {
throw new CustomError('start is higher than the current chatHead', 'apierror');
}
if (end > chatHead) {
throw new CustomError('end is higher than the current chatHead', 'apierror');
}
// the the whole message-log and return it to the client
const messages = await pad.getChatMessages(start, end);
return {messages};
};
/**
appendChatMessage(padID, text, authorID, time), creates a chat message for the pad id,
time is a timestamp
Example returns:
{code: 0, message:"ok", data: null}
{code: 1, message:"padID does not exist", data: null}
*/
exports.appendChatMessage = async (padID, text, authorID, time) => {
// text is required
if (typeof text !== 'string') {
throw new CustomError('text is not a string', 'apierror');
}
// if time is not an integer value set time to current timestamp
if (time === undefined || !isInt(time)) {
time = Date.now();
}
// @TODO - missing getPadSafe() call ?
// save chat message to database and send message to all connected clients
await padMessageHandler.sendChatMessageToPadClients(new ChatMessage(text, authorID, time), padID);
};
exports.registerChatHandlers = (handlers) => Object.assign(exports, handlers);
/* ***************
* PAD FUNCTIONS *
@ -732,20 +656,6 @@ Example returns:
exports.checkToken = async () => {
};
/**
getChatHead(padID) returns the chatHead (last number of the last chat-message) of the pad
Example returns:
{code: 0, message:"ok", data: {chatHead: 42}}
{code: 1, message:"padID does not exist", data: null}
*/
exports.getChatHead = async (padID) => {
// get the pad
const pad = await getPadSafe(padID, true);
return {chatHead: pad.chatHead};
};
/**
createDiffHTML(padID, startRev, endRev) returns an object of diffs from 2 points in a pad
@ -851,7 +761,7 @@ const getPadSafe = async (padID, shouldExist, text, authorId = '') => {
}
// pad exists, let's get it
return padManager.getPad(padID, text, authorId);
return await padManager.getPad(padID, text, authorId);
};
// checks if a rev is a legal number

View File

@ -22,6 +22,10 @@ const hooks = require('../../static/js/pluginfw/hooks');
const {padutils: {warnDeprecated}} = require('../../static/js/pad_utils');
const promises = require('../utils/promises');
let chat = null;
exports.registerLegacyChatMethodHandlers = (handlers) => chat = handlers;
/**
* Copied from the Etherpad source code. It converts Windows line breaks to Unix
* line breaks and convert Tabs to spaces
@ -45,7 +49,6 @@ class Pad {
this.atext = Changeset.makeAText('\n');
this.pool = new AttributePool();
this.head = -1;
this.chatHead = -1;
this.publicStatus = false;
this.id = id;
this.savedRevisions = [];
@ -287,6 +290,7 @@ class Pad {
/**
* Adds a chat message to the pad, including saving it to the database.
*
* @deprecated
* @param {(ChatMessage|string)} msgOrText - Either a chat message object (recommended) or a
* string containing the raw text of the user's chat message (deprecated).
* @param {?string} [authorId] - The user's author ID. Deprecated; use `msgOrText.authorId`
@ -295,31 +299,24 @@ class Pad {
* `msgOrText.time` instead.
*/
async appendChatMessage(msgOrText, authorId = null, time = null) {
warnDeprecated('Pad.appendChatMessage() is deprecated');
const msg =
msgOrText instanceof ChatMessage ? msgOrText : new ChatMessage(msgOrText, authorId, time);
this.chatHead++;
await Promise.all([
// Don't save the display name in the database because the user can change it at any time. The
// `displayName` property will be populated with the current value when the message is read
// from the database.
this.db.set(`pad:${this.id}:chat:${this.chatHead}`, {...msg, displayName: undefined}),
this.saveToDatabase(),
]);
await chat.appendChatMessage(this, msg);
}
/**
* @deprecated
* @param {number} entryNum - ID of the desired chat message.
* @returns {?ChatMessage}
*/
async getChatMessage(entryNum) {
const entry = await this.db.get(`pad:${this.id}:chat:${entryNum}`);
if (entry == null) return null;
const message = ChatMessage.fromObject(entry);
message.displayName = await authorManager.getAuthorName(message.authorId);
return message;
warnDeprecated('Pad.getChatMessage() is deprecated');
return await chat.getChatMessage(this, entryNum);
}
/**
* @deprecated
* @param {number} start - ID of the first desired chat message.
* @param {number} end - ID of the last desired chat message.
* @returns {ChatMessage[]} Any existing messages with IDs between `start` (inclusive) and `end`
@ -327,19 +324,8 @@ class Pad {
* interval as is typical in code.
*/
async getChatMessages(start, end) {
const entries =
await Promise.all(Stream.range(start, end + 1).map(this.getChatMessage.bind(this)));
// sort out broken chat entries
// it looks like in happened in the past that the chat head was
// incremented, but the chat message wasn't added
return entries.filter((entry) => {
const pass = (entry != null);
if (!pass) {
console.warn(`WARNING: Found broken chat entry in pad ${this.id}`);
}
return pass;
});
warnDeprecated('Pad.getChatMessages() is deprecated');
return await chat.getChatMessages(this, start, end);
}
async init(text, authorId = '') {
@ -386,7 +372,6 @@ class Pad {
const promises = (function* () {
yield copyRecord('');
yield* Stream.range(0, this.head + 1).map((i) => copyRecord(`:revs:${i}`));
yield* Stream.range(0, this.chatHead + 1).map((i) => copyRecord(`:chat:${i}`));
yield this.copyAuthorInfoToDestinationPad(destinationID);
if (destGroupID) yield db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1);
}).call(this);
@ -545,11 +530,6 @@ class Pad {
}));
p.push(db.remove(`pad2readonly:${padID}`));
// delete all chat messages
p.push(promises.timesLimit(this.chatHead + 1, 500, async (i) => {
await this.db.remove(`pad:${this.id}:chat:${i}`, null);
}));
// delete all revisions
p.push(promises.timesLimit(this.head + 1, 500, async (i) => {
await this.db.remove(`pad:${this.id}:revs:${i}`, null);
@ -703,23 +683,6 @@ class Pad {
assert.deepEqual(this.atext, atext);
assert.deepEqual(this.getAllAuthors().sort(), [...authorIds].sort());
assert(this.chatHead != null);
assert(Number.isInteger(this.chatHead));
assert(this.chatHead >= -1);
const chats = Stream.range(0, this.chatHead + 1)
.map(async (c) => {
try {
const msg = await this.getChatMessage(c);
assert(msg != null);
assert(msg instanceof ChatMessage);
} catch (err) {
err.message = `(pad ${this.id} chat message ${c}) ${err.message}`;
throw err;
}
})
.batch(100).buffer(99);
for (const p of chats) await p;
await hooks.aCallAll('padCheck', {pad: this});
}
}

View File

@ -40,6 +40,7 @@ const assert = require('assert').strict;
const {RateLimiterMemory} = require('rate-limiter-flexible');
const webaccess = require('../hooks/express/webaccess');
let chat = null;
let rateLimiter;
let socketio = null;
@ -54,6 +55,8 @@ const addContextToError = (err, pfx) => {
return err;
};
exports.registerLegacyChatHandlers = (handlers) => chat = handlers;
exports.socketio = () => {
// The rate limiter is created in this hook so that restarting the server resets the limiter. The
// settings.commitRateLimiting object is passed directly to the rate limiter so that the limits
@ -335,8 +338,6 @@ exports.handleMessage = async (socket, message) => {
await padChannels.enqueue(thisSession.padId, {socket, message});
break;
case 'USERINFO_UPDATE': await handleUserInfoUpdate(socket, message); break;
case 'CHAT_MESSAGE': await handleChatMessage(socket, message); break;
case 'GET_CHAT_MESSAGES': await handleGetChatMessages(socket, message); break;
case 'SAVE_REVISION': await handleSaveRevisionMessage(socket, message); break;
case 'CLIENT_MESSAGE': {
const {type} = message.data.payload;
@ -413,23 +414,10 @@ exports.handleCustomMessage = (padID, msgString) => {
socketio.sockets.in(padID).json.send(msg);
};
/**
* Handles a Chat Message
* @param socket the socket.io Socket object for the client
* @param message the message from the client
*/
const handleChatMessage = async (socket, message) => {
const chatMessage = ChatMessage.fromObject(message.data.message);
const {padId, author: authorId} = sessioninfos[socket.id];
// Don't trust the user-supplied values.
chatMessage.time = Date.now();
chatMessage.authorId = authorId;
await exports.sendChatMessageToPadClients(chatMessage, padId);
};
/**
* Adds a new chat message to a pad and sends it to connected clients.
*
* @deprecated Use chat.sendChatMessageToPadClients() instead.
* @param {(ChatMessage|number)} mt - Either a chat message object (recommended) or the timestamp of
* the chat message in ms since epoch (deprecated).
* @param {string} puId - If `mt` is a chat message object, this is the destination pad ID.
@ -439,45 +427,11 @@ const handleChatMessage = async (socket, message) => {
* object as the first argument and the destination pad ID as the second argument instead.
*/
exports.sendChatMessageToPadClients = async (mt, puId, text = null, padId = null) => {
padutils.warnDeprecated('PadMessageHandler.sendChatMessageToPadClients() is deprecated; ' +
'use chat.sendChatMessageToPadClients() instead');
const message = mt instanceof ChatMessage ? mt : new ChatMessage(text, puId, mt);
padId = mt instanceof ChatMessage ? puId : padId;
const pad = await padManager.getPad(padId, null, message.authorId);
await hooks.aCallAll('chatNewMessage', {message, pad, padId});
// pad.appendChatMessage() ignores the displayName property so we don't need to wait for
// authorManager.getAuthorName() to resolve before saving the message to the database.
const promise = pad.appendChatMessage(message);
message.displayName = await authorManager.getAuthorName(message.authorId);
socketio.sockets.in(padId).json.send({
type: 'COLLABROOM',
data: {type: 'CHAT_MESSAGE', message},
});
await promise;
};
/**
* Handles the clients request for more chat-messages
* @param socket the socket.io Socket object for the client
* @param message the message from the client
*/
const handleGetChatMessages = async (socket, {data: {start, end}}) => {
if (!Number.isInteger(start)) throw new Error(`missing or invalid start: ${start}`);
if (!Number.isInteger(end)) throw new Error(`missing or invalid end: ${end}`);
const count = end - start;
if (count < 0 || count > 100) throw new Error(`invalid number of messages: ${count}`);
const {padId, author: authorId} = sessioninfos[socket.id];
const pad = await padManager.getPad(padId, null, authorId);
const chatMessages = await pad.getChatMessages(start, end);
const infoMsg = {
type: 'COLLABROOM',
data: {
type: 'CHAT_MESSAGES',
messages: chatMessages,
},
};
// send the messages back to the client
socket.json.send(infoMsg);
await chat.sendChatMessageToPadClients(message, padId);
};
/**
@ -949,9 +903,6 @@ const handleClientReady = async (socket, message) => {
padShortcutEnabled: settings.padShortcutEnabled,
initialTitle: `Pad: ${sessionInfo.auth.padID}`,
opts: {},
// tell the client the number of the latest chat-message, which will be
// used to request the latest 100 chat-messages later (GET_CHAT_MESSAGES)
chatHead: pad.chatHead,
numConnectedUsers: roomSockets.length,
readOnlyId: sessionInfo.readOnlyPadId,
readonly: sessionInfo.readonly,

View File

@ -1,15 +1,115 @@
'use strict';
const fs = require('fs').promises;
const minify = require('../../utils/Minify');
const path = require('path');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const settings = require('../../utils/Settings');
const CachingMiddleware = require('../../utils/caching_middleware');
const Yajsml = require('etherpad-yajsml');
// Rewrite tar to include modules with no extensions and proper rooted paths.
const getTar = async () => {
const tar = (() => {
const associations = {
'pad.js': [
'pad.js',
'pad_utils.js',
'$js-cookie/dist/js.cookie.js',
'security.js',
'$security.js',
'vendors/browser.js',
'pad_cookie.js',
'pad_editor.js',
'pad_editbar.js',
'vendors/nice-select.js',
'pad_modals.js',
'pad_automatic_reconnect.js',
'ace.js',
'collab_client.js',
'cssmanager.js',
'pad_userlist.js',
'pad_impexp.js',
'pad_savedrevs.js',
'pad_connectionstatus.js',
...settings.integratedChat ? [
'ChatMessage.js',
'chat.js',
'$tinycon/tinycon.js',
] : [],
'vendors/gritter.js',
'$js-cookie/dist/js.cookie.js',
'vendors/farbtastic.js',
'skin_variants.js',
'socketio.js',
'colorutils.js',
],
'timeslider.js': [
'timeslider.js',
'colorutils.js',
'draggable.js',
'pad_utils.js',
'$js-cookie/dist/js.cookie.js',
'vendors/browser.js',
'pad_cookie.js',
'pad_editor.js',
'pad_editbar.js',
'vendors/nice-select.js',
'pad_modals.js',
'pad_automatic_reconnect.js',
'pad_savedrevs.js',
'pad_impexp.js',
'AttributePool.js',
'Changeset.js',
'domline.js',
'linestylefilter.js',
'cssmanager.js',
'broadcast.js',
'broadcast_slider.js',
'broadcast_revisions.js',
'socketio.js',
'AttributeManager.js',
'AttributeMap.js',
'attributes.js',
'ChangesetUtils.js',
],
'ace2_inner.js': [
'ace2_inner.js',
'vendors/browser.js',
'AttributePool.js',
'Changeset.js',
'ChangesetUtils.js',
'skiplist.js',
'colorutils.js',
'undomodule.js',
'$unorm/lib/unorm.js',
'contentcollector.js',
'changesettracker.js',
'linestylefilter.js',
'domline.js',
'AttributeManager.js',
'AttributeMap.js',
'attributes.js',
'scroll.js',
'caretPosition.js',
'pad_utils.js',
'$js-cookie/dist/js.cookie.js',
'security.js',
'$security.js',
],
'ace2_common.js': [
'ace2_common.js',
'vendors/browser.js',
'vendors/jquery.js',
'rjquery.js',
'$async.js',
'underscore.js',
'$underscore.js',
'$underscore/underscore.js',
'security.js',
'$security.js',
'pluginfw/client_plugins.js',
'pluginfw/plugin_defs.js',
'pluginfw/shared.js',
'pluginfw/hooks.js',
],
};
const prefixLocalLibraryPath = (path) => {
if (path.charAt(0) === '$') {
return path.slice(1);
@ -17,16 +117,15 @@ const getTar = async () => {
return `ep_etherpad-lite/static/js/${path}`;
}
};
const tarJson = await fs.readFile(path.join(settings.root, 'src/node/utils/tar.json'), 'utf8');
const tar = {};
for (const [key, relativeFiles] of Object.entries(JSON.parse(tarJson))) {
for (const [key, relativeFiles] of Object.entries(associations)) {
const files = relativeFiles.map(prefixLocalLibraryPath);
tar[prefixLocalLibraryPath(key)] = files
.concat(files.map((p) => p.replace(/\.js$/, '')))
.concat(files.map((p) => `${p.replace(/\.js$/, '')}/index.js`));
}
return tar;
};
})();
exports.expressPreSession = async (hookName, {app}) => {
// Cache both minified and static.
@ -49,7 +148,7 @@ exports.expressPreSession = async (hookName, {app}) => {
});
const StaticAssociator = Yajsml.associators.StaticAssociator;
const associations = Yajsml.associators.associationsForSimpleMapping(await getTar());
const associations = Yajsml.associators.associationsForSimpleMapping(tar);
const associator = new StaticAssociator(associations);
jsServer.setAssociator(associator);
@ -59,10 +158,22 @@ exports.expressPreSession = async (hookName, {app}) => {
// not very static, but served here so that client can do
// require("pluginfw/static/js/plugin-definitions.js");
app.get('/pluginfw/plugin-definitions.json', (req, res, next) => {
const clientParts = plugins.parts.filter((part) => part.client_hooks != null);
// No need to tell clients about server-side hooks.
const stripServerSideHooks = (parts) => parts.reduce((parts, part) => {
if (part.client_hooks != null) {
if (part.hooks) part = {...part, hooks: undefined};
parts.push(part);
}
return parts;
}, []);
const clientParts = stripServerSideHooks(plugins.parts);
const clientPlugins = {};
for (const name of new Set(clientParts.map((part) => part.plugin))) {
clientPlugins[name] = {...plugins.plugins[name]};
const plugin = plugins.plugins[name];
clientPlugins[name] = {
...plugin,
parts: stripServerSideHooks(plugin.parts),
};
delete clientPlugins[name].package;
}
res.setHeader('Content-Type', 'application/json; charset=utf-8');

View File

@ -38,8 +38,10 @@ exports.expressPreSession = async (hookName, {app}) => {
if (!pluginPath.endsWith(path.sep)) pluginPath += path.sep;
const specDir = `${plugin === 'ep_etherpad-lite' ? '' : 'static/'}tests/frontend/specs`;
for (const spec of await findSpecs(path.join(pluginPath, specDir))) {
if (plugin === 'ep_etherpad-lite' && !settings.enableAdminUITests &&
spec.startsWith('admin')) continue;
if (plugin === 'ep_etherpad-lite') {
if (!settings.enableAdminUITests && spec.startsWith('admin')) continue;
if (!settings.integratedChat && spec.startsWith('chat')) continue;
}
modules.push(`${plugin}/${specDir}/${spec.replace(/\.js$/, '')}`);
}
}));

View File

@ -48,7 +48,6 @@ const UpdateCheck = require('./utils/UpdateCheck');
const db = require('./db/DB');
const express = require('./hooks/express');
const hooks = require('../static/js/pluginfw/hooks');
const pluginDefs = require('../static/js/pluginfw/plugin_defs');
const plugins = require('../static/js/pluginfw/plugins');
const {Gate} = require('./utils/promises');
const stats = require('./stats');
@ -134,13 +133,6 @@ exports.start = async () => {
await db.init();
await plugins.update();
const installedPlugins = Object.values(pluginDefs.plugins)
.filter((plugin) => plugin.package.name !== 'ep_etherpad-lite')
.map((plugin) => `${plugin.package.name}@${plugin.package.version}`)
.join(', ');
logger.info(`Installed plugins: ${installedPlugins}`);
logger.debug(`Installed parts:\n${plugins.formatParts()}`);
logger.debug(`Installed server-side hooks:\n${plugins.formatHooks('hooks', false)}`);
await hooks.aCallAll('loadSettings', {settings});
await hooks.aCallAll('createServer');
} catch (err) {

View File

@ -50,7 +50,6 @@ exports.getPadRaw = async (padId, readOnlyId) => {
})()];
}
for (let i = 0; i <= pad.head; ++i) yield [`${dstPfx}:revs:${i}`, pad.getRevision(i)];
for (let i = 0; i <= pad.chatHead; ++i) yield [`${dstPfx}:chat:${i}`, pad.getChatMessage(i)];
for (const gen of pluginRecords) yield* gen;
})();
const data = {[dstPfx]: pad};

View File

@ -74,7 +74,9 @@ exports.setPadRaw = async (padId, r, authorId = '') => {
return;
}
value.padIDs = {[padId]: 1};
} else if (padKeyPrefixes.includes(prefix)) {
} else if (padKeyPrefixes.includes(prefix) &&
// Chat message handling was moved to the importEtherpad hook.
(keyParts[0] !== 'pad' || keyParts[2] !== 'chat')) {
checkOriginalPadId(id);
if (prefix === 'pad' && keyParts.length === 2) {
const pool = new AttributePool().fromJsonable(value.pool);

View File

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

View File

@ -156,6 +156,12 @@ exports.defaultPadText = [
'Etherpad on Github: https://github.com/ether/etherpad-lite',
].join('\n');
/**
* Whether to enable the built-in chat feature. Set this to false if you prefer to use a plugin to
* provide chat functionality or simply do not want the feature.
*/
exports.integratedChat = true;
/**
* The default Pad Settings for a user (Can be overridden by changing the setting
*/

View File

@ -1,101 +0,0 @@
{
"pad.js": [
"pad.js"
, "pad_utils.js"
, "$js-cookie/dist/js.cookie.js"
, "security.js"
, "$security.js"
, "vendors/browser.js"
, "pad_cookie.js"
, "pad_editor.js"
, "pad_editbar.js"
, "vendors/nice-select.js"
, "pad_modals.js"
, "pad_automatic_reconnect.js"
, "ace.js"
, "collab_client.js"
, "cssmanager.js"
, "pad_userlist.js"
, "pad_impexp.js"
, "pad_savedrevs.js"
, "pad_connectionstatus.js"
, "ChatMessage.js"
, "chat.js"
, "vendors/gritter.js"
, "$js-cookie/dist/js.cookie.js"
, "$tinycon/tinycon.js"
, "vendors/farbtastic.js"
, "skin_variants.js"
, "socketio.js"
, "colorutils.js"
]
, "timeslider.js": [
"timeslider.js"
, "colorutils.js"
, "draggable.js"
, "pad_utils.js"
, "$js-cookie/dist/js.cookie.js"
, "vendors/browser.js"
, "pad_cookie.js"
, "pad_editor.js"
, "pad_editbar.js"
, "vendors/nice-select.js"
, "pad_modals.js"
, "pad_automatic_reconnect.js"
, "pad_savedrevs.js"
, "pad_impexp.js"
, "AttributePool.js"
, "Changeset.js"
, "domline.js"
, "linestylefilter.js"
, "cssmanager.js"
, "broadcast.js"
, "broadcast_slider.js"
, "broadcast_revisions.js"
, "socketio.js"
, "AttributeManager.js"
, "AttributeMap.js"
, "attributes.js"
, "ChangesetUtils.js"
]
, "ace2_inner.js": [
"ace2_inner.js"
, "vendors/browser.js"
, "AttributePool.js"
, "Changeset.js"
, "ChangesetUtils.js"
, "skiplist.js"
, "colorutils.js"
, "undomodule.js"
, "$unorm/lib/unorm.js"
, "contentcollector.js"
, "changesettracker.js"
, "linestylefilter.js"
, "domline.js"
, "AttributeManager.js"
, "AttributeMap.js"
, "attributes.js"
, "scroll.js"
, "caretPosition.js"
, "pad_utils.js"
, "$js-cookie/dist/js.cookie.js"
, "security.js"
, "$security.js"
]
, "ace2_common.js": [
"ace2_common.js"
, "vendors/browser.js"
, "vendors/jquery.js"
, "rjquery.js"
, "$async.js"
, "underscore.js"
, "$underscore.js"
, "$underscore/underscore.js"
, "security.js"
, "$security.js"
, "pluginfw/client_plugins.js"
, "pluginfw/plugin_defs.js"
, "pluginfw/shared.js"
, "pluginfw/hooks.js"
]
}

View File

@ -46,7 +46,7 @@ body {
max-width: 40%;
flex-shrink: 0;
}
#editorcontainerbox .sticky-container:not(.stikyUsers):not(.stickyChat) {
#editorcontainerbox .sticky-container:not(.stickyUsers):not(.stickyChat) {
width: 0; /* hide when the container is empty */
}

View File

@ -2585,15 +2585,6 @@ function Ace2Inner(editorInfo, cssManagers) {
firstEditbarElement.focus();
evt.preventDefault();
}
if (!specialHandled && type === 'keydown' &&
altKey && keyCode === 67 &&
padShortcutEnabled.altC) {
// Alt c focuses on the Chat window
$(this).blur();
parent.parent.chat.show();
parent.parent.$('#chatinput').focus();
evt.preventDefault();
}
if (!specialHandled && type === 'keydown' &&
evt.ctrlKey && shiftKey && keyCode === 50 &&
padShortcutEnabled.cmdShift2) {

View File

@ -25,7 +25,7 @@ const padeditor = require('./pad_editor').padeditor;
// Removes diacritics and lower-cases letters. https://stackoverflow.com/a/37511463
const normalize = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
exports.chat = (() => {
const chat = (() => {
let isStuck = false;
let userAndChat = false;
let chatMentions = 0;
@ -79,17 +79,35 @@ exports.chat = (() => {
.toggleClass('chatAndUsers popup-show stickyUsers', userAndChat);
$('#chatbox').toggleClass('chatAndUsersChat', userAndChat);
},
hide() {
reduce() {
// decide on hide logic based on chat window being maximized or not
if ($('#options-stickychat').prop('checked')) {
this.stickToScreen();
$('#options-stickychat').prop('checked', false);
} else {
$('#chatcounter').text('0');
// It usually is not necessary to call .show() because .hide() is only normally called when
// the pad is loaded and showChat=false. When showChat=false, there are no chat UI elements
// so there's nothing to click on to get the chatbox to display. However, there are other
// ways to get the chatbox to display:
// * A plugin might call `chat.show()`.
// * The user can hit Alt-C (assuming the shortcut is enabled).
// * The user can run `chat.show()` in the developer console.
// In all cases, reducing the shown chatbox should cause it to minimize to an icon, not
// vanish completely.
$('#chaticon').show();
$('#chaticon').addClass('visible');
$('#chatbox').removeClass('visible');
}
},
minimize() {
if ($('#options-stickychat').prop('checked')) this.reduce();
this.reduce();
},
hide() {
this.minimize();
$('#chaticon').hide();
},
scrollDown(force) {
if ($('#chatbox').hasClass('visible')) {
if (force || !this.lastMessage || !this.lastMessage.position() ||
@ -218,6 +236,11 @@ exports.chat = (() => {
},
init(pad) {
this._pad = pad;
$('#options-stickychat').on('click', () => this.stickToScreen());
$('#options-chatandusers').on('click', () => this.chatAndUsers());
$('#chaticon').on('click', () => { this.show(); return false; });
$('#titlecross').on('click', () => { this.reduce(); return false; });
$('#titlesticky').on('click', () => { this.stickToScreen(true); return false; });
$('#chatinput').on('keydown', (evt) => {
// If the event is Alt C or Escape & we're already in the chat menu
// Send the users focus back to the pad
@ -235,17 +258,6 @@ exports.chat = (() => {
Tinycon.setBubble(0);
});
const self = this;
$('body:not(#chatinput)').on('keypress', function (evt) {
if (evt.altKey && evt.which === 67) {
// Alt c focuses on the Chat window
$(this).blur();
self.show();
$('#chatinput').focus();
evt.preventDefault();
}
});
$('#chatinput').keypress((evt) => {
// if the user typed enter, fire the send
if (evt.key === 'Enter' && !evt.shiftKey) {
@ -269,6 +281,110 @@ exports.chat = (() => {
pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end});
this.historyPointer = start;
});
const {searchParams} = new URL(window.location.href);
const {showChat = true, alwaysShowChat = false, chatAndUsers = false} = clientVars.padOptions;
const settings = this._pad.settings;
settings.hideChat = showChat.toString() === 'false';
if (settings.hideChat) this.hide();
if (alwaysShowChat.toString() === 'true' && !settings.hideChat) this.stickToScreen();
if (chatAndUsers.toString() === 'true') this.chatAndUsers();
settings.hideChat = searchParams.get('showChat') === 'false';
if (settings.hideChat) this.hide();
if (searchParams.get('alwaysShowChat') === 'true' && !settings.hideChat) this.stickToScreen();
if (searchParams.get('chatAndUsers') === 'true') this.chatAndUsers();
const chatVisCookie = !!padcookie.getPref('chatAlwaysVisible');
if (chatVisCookie) this.stickToScreen(true);
$('#options-stickychat').prop('checked', chatVisCookie);
const chatAUVisCookie = !!padcookie.getPref('chatAndUsersVisible');
if (chatAUVisCookie) this.chatAndUsers(true);
$('#options-chatandusers').prop('checked', chatAUVisCookie);
},
};
})();
Object.defineProperty(exports, 'chat', {
get: () => {
padutils.warnDeprecated(
'chat.chat is deprecated and will be removed in a future version of Etherpad');
return chat;
},
});
exports.aceKeyEvent = (hookName, {evt}) => {
const {altC} = window.clientVars.padShortcutEnabled;
if (evt.type !== 'keydown' || !evt.altKey || evt.keyCode !== 67 || !altC) return;
evt.target.blur();
chat.show();
chat.focus();
evt.preventDefault();
return true;
};
exports.handleClientMessage_CHAT_MESSAGE = (hookName, {msg}) => {
chat.addMessage(msg.message, true, false);
};
exports.handleClientMessage_CHAT_MESSAGES = (hookName, {msg}) => {
for (let i = msg.messages.length - 1; i >= 0; i--) {
chat.addMessage(msg.messages[i], true, true);
}
if (!chat.gotInitalMessages) {
chat.scrollDown();
chat.gotInitalMessages = true;
chat.historyPointer = clientVars.chatHead - msg.messages.length;
}
// messages are loaded, so hide the loading-ball
$('#chatloadmessagesball').css('display', 'none');
// there are less than 100 messages or we reached the top
if (chat.historyPointer <= 0) {
$('#chatloadmessagesbutton').css('display', 'none');
} else {
// there are still more messages, re-show the load-button
$('#chatloadmessagesbutton').css('display', 'block');
}
};
exports.postAceInit = async (hookName, {clientVars, pad}) => {
chat.init(pad);
if (padcookie.getPref('chatAlwaysVisible')) {
chat.stickToScreen(true);
$('#options-stickychat').prop('checked', true);
}
if (padcookie.getPref('chatAndUsers')) {
chat.chatAndUsers(true);
$('#options-chatandusers').prop('checked', true);
}
// Prevent sticky chat or chat and users to be checked for mobiles
const checkChatAndUsersVisibility = (x) => {
if (!x.matches) return;
$('#options-chatandusers:checked').click();
$('#options-stickychat:checked').click();
};
const mobileMatch = window.matchMedia('(max-width: 800px)');
mobileMatch.addListener(checkChatAndUsersVisibility);
setTimeout(() => { checkChatAndUsersVisibility(mobileMatch); }, 0);
if (clientVars.chatHead !== -1) {
const chatHead = clientVars.chatHead;
const start = Math.max(chatHead - 100, 0);
pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end: chatHead});
} else {
$('#chatloadmessagesbutton').css('display', 'none');
}
if (clientVars.readonly) {
chat.hide();
$('#chatinput').attr('disabled', true);
$('#options-chatandusers').parent().hide();
$('#options-stickychat').parent().hide();
} else if (!pad.settings.hideChat) {
$('#chaticon').show();
}
};

View File

@ -22,7 +22,6 @@
* limitations under the License.
*/
const chat = require('./chat').chat;
const hooks = require('./pluginfw/hooks');
const browser = require('./vendors/browser');
@ -266,28 +265,6 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
}
} else if (msg.type === 'CLIENT_MESSAGE') {
callbacks.onClientMessage(msg.payload);
} else if (msg.type === 'CHAT_MESSAGE') {
chat.addMessage(msg.message, true, false);
} else if (msg.type === 'CHAT_MESSAGES') {
for (let i = msg.messages.length - 1; i >= 0; i--) {
chat.addMessage(msg.messages[i], true, true);
}
if (!chat.gotInitalMessages) {
chat.scrollDown();
chat.gotInitalMessages = true;
chat.historyPointer = clientVars.chatHead - msg.messages.length;
}
// messages are loaded, so hide the loading-ball
$('#chatloadmessagesball').css('display', 'none');
// there are less than 100 messages or we reached the top
if (chat.historyPointer <= 0) {
$('#chatloadmessagesbutton').css('display', 'none');
} else {
// there are still more messages, re-show the load-button
$('#chatloadmessagesbutton').css('display', 'block');
}
}
// HACKISH: User messages do not have "payload" but "userInfo", so that all
@ -300,7 +277,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
// Similar for NEW_CHANGES
if (msg.type === 'NEW_CHANGES') msg.payload = msg;
hooks.callAll(`handleClientMessage_${msg.type}`, {payload: msg.payload});
hooks.callAll(`handleClientMessage_${msg.type}`, {msg, payload: msg.payload});
};
const updateUserInfo = (userInfo) => {

View File

@ -31,7 +31,6 @@ require('./vendors/farbtastic');
require('./vendors/gritter');
const Cookies = require('./pad_utils').Cookies;
const chat = require('./chat').chat;
const getCollabClient = require('./collab_client').getCollabClient;
const padconnectionstatus = require('./pad_connectionstatus').padconnectionstatus;
const padcookie = require('./pad_cookie').padcookie;
@ -70,17 +69,6 @@ const getParameters = [
$('#editbar').css('display', 'flex');
},
},
{
name: 'showChat',
checkVal: null,
callback: (val) => {
if (val === 'false') {
settings.hideChat = true;
chat.hide();
$('#chaticon').hide();
}
},
},
{
name: 'showLineNumbers',
checkVal: 'false',
@ -118,20 +106,6 @@ const getParameters = [
settings.rtlIsTrue = true;
},
},
{
name: 'alwaysShowChat',
checkVal: 'true',
callback: (val) => {
if (!settings.hideChat) chat.stickToScreen();
},
},
{
name: 'chatAndUsers',
checkVal: 'true',
callback: (val) => {
chat.chatAndUsers();
},
},
{
name: 'lang',
checkVal: null,
@ -392,8 +366,6 @@ const pad = {
},
_afterHandshake() {
pad.clientTimeOffset = Date.now() - clientVars.serverTimestamp;
// initialize the chat
chat.init(this);
getParams();
padcookie.init(); // initialize the cookies
@ -412,16 +384,6 @@ const pad = {
setTimeout(() => {
padeditor.ace.focus();
}, 0);
// if we have a cookie for always showing chat then show it
if (padcookie.getPref('chatAlwaysVisible')) {
chat.stickToScreen(true); // stick it to the screen
$('#options-stickychat').prop('checked', true); // set the checkbox to on
}
// if we have a cookie for always showing chat then show it
if (padcookie.getPref('chatAndUsers')) {
chat.chatAndUsers(true); // stick it to the screen
$('#options-chatandusers').prop('checked', true); // set the checkbox to on
}
if (padcookie.getPref('showAuthorshipColors') === false) {
pad.changeViewOption('showAuthorColors', false);
}
@ -434,17 +396,6 @@ const pad = {
pad.changeViewOption('padFontFamily', padcookie.getPref('padFontFamily'));
$('#viewfontmenu').val(padcookie.getPref('padFontFamily')).niceSelect('update');
// Prevent sticky chat or chat and users to be checked for mobiles
const checkChatAndUsersVisibility = (x) => {
if (x.matches) { // If media query matches
$('#options-chatandusers:checked').click();
$('#options-stickychat:checked').click();
}
};
const mobileMatch = window.matchMedia('(max-width: 800px)');
mobileMatch.addListener(checkChatAndUsersVisibility); // check if window resized
setTimeout(() => { checkChatAndUsersVisibility(mobileMatch); }, 0); // check now after load
$('#editorcontainer').addClass('initialized');
hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad});
@ -469,24 +420,7 @@ const pad = {
pad.collabClient.setOnChannelStateChange(pad.handleChannelStateChange);
pad.collabClient.setOnInternalAction(pad.handleCollabAction);
// load initial chat-messages
if (clientVars.chatHead !== -1) {
const chatHead = clientVars.chatHead;
const start = Math.max(chatHead - 100, 0);
pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end: chatHead});
} else {
// there are no messages
$('#chatloadmessagesbutton').css('display', 'none');
}
if (window.clientVars.readonly) {
chat.hide();
$('#myusernameedit').attr('disabled', true);
$('#chatinput').attr('disabled', true);
$('#chaticon').hide();
$('#options-chatandusers').parent().hide();
$('#options-stickychat').parent().hide();
} else if (!settings.hideChat) { $('#chaticon').show(); }
if (window.clientVars.readonly) $('#myusernameedit').attr('disabled', true);
$('body').addClass(window.clientVars.readonly ? 'readonly' : 'readwrite');
@ -650,31 +584,11 @@ const pad = {
}
},
handleIsFullyConnected: (isConnected, isInitialConnect) => {
pad.determineChatVisibility(isConnected && !isInitialConnect);
pad.determineChatAndUsersVisibility(isConnected && !isInitialConnect);
pad.determineAuthorshipColorsVisibility();
setTimeout(() => {
padeditbar.toggleDropDown('none');
}, 1000);
},
determineChatVisibility: (asNowConnectedFeedback) => {
const chatVisCookie = padcookie.getPref('chatAlwaysVisible');
if (chatVisCookie) { // if the cookie is set for chat always visible
chat.stickToScreen(true); // stick it to the screen
$('#options-stickychat').prop('checked', true); // set the checkbox to on
} else {
$('#options-stickychat').prop('checked', false); // set the checkbox for off
}
},
determineChatAndUsersVisibility: (asNowConnectedFeedback) => {
const chatAUVisCookie = padcookie.getPref('chatAndUsersVisible');
if (chatAUVisCookie) { // if the cookie is set for chat always visible
chat.chatAndUsers(true); // stick it to the screen
$('#options-chatandusers').prop('checked', true); // set the checkbox to on
} else {
$('#options-chatandusers').prop('checked', false); // set the checkbox for off
}
},
determineAuthorshipColorsVisibility: () => {
const authColCookie = padcookie.getPref('showAuthorshipColors');
if (authColCookie) {

View File

@ -5,6 +5,7 @@ const hooks = require('./hooks');
const log4js = require('log4js');
const path = require('path');
const runCmd = require('../../../node/utils/run_cmd');
const settings = require('../../../node/utils/Settings');
const tsort = require('./tsort');
const pluginUtils = require('./shared');
const defs = require('./plugin_defs');
@ -102,6 +103,13 @@ exports.update = async () => {
const logger = log4js.getLogger(`plugin:${p}`);
await hooks.aCallAll(`init_${p}`, {logger});
}));
const installedPlugins = Object.values(defs.plugins)
.filter((plugin) => plugin.package.name !== 'ep_etherpad-lite')
.map((plugin) => `${plugin.package.name}@${plugin.package.version}`)
.join(', ');
logger.info(`Installed plugins: ${installedPlugins}`);
logger.debug(`Installed parts:\n${exports.formatParts()}`);
logger.debug(`Installed server-side hooks:\n${exports.formatHooks('hooks', false)}`);
};
exports.getPackages = async () => {
@ -129,6 +137,9 @@ const loadPlugin = async (packages, pluginName, plugins, parts) => {
const data = await fs.readFile(pluginPath);
try {
const plugin = JSON.parse(data);
if (pluginName === 'ep_etherpad-lite' && !settings.integratedChat) {
plugin.parts = plugin.parts.filter((part) => part.name !== 'chat');
}
plugin.package = packages[pluginName];
plugins[pluginName] = plugin;
for (const part of plugin.parts) {

View File

@ -62,348 +62,321 @@
<!--------- TOOLBAR ----------->
<!----------------------------->
<div id="editbar" class="toolbar">
<div id="toolbar-overlay"></div>
<div id="toolbar-overlay"></div>
<ul class="menu_left" role="toolbar">
<% e.begin_block("editbarMenuLeft"); %>
<%- toolbar.menu(settings.toolbar.left, isReadOnly, 'left', 'pad') %>
<% e.end_block(); %>
</ul>
<ul class="menu_right" role="toolbar">
<% e.begin_block("editbarMenuRight"); %>
<%- toolbar.menu(settings.toolbar.right, isReadOnly, 'right', 'pad') %>
<% e.end_block(); %>
</ul>
<span class="show-more-icon-btn"></span> <!-- use on small screen to display hidden toolbar buttons -->
<ul class="menu_left" role="toolbar">
<% e.begin_block("editbarMenuLeft"); %>
<%- toolbar.menu(settings.toolbar.left, isReadOnly, 'left', 'pad') %>
<% e.end_block(); %>
</ul>
<ul class="menu_right" role="toolbar">
<% e.begin_block("editbarMenuRight"); %>
<%- toolbar.menu(settings.toolbar.right, isReadOnly, 'right', 'pad') %>
<% e.end_block(); %>
</ul>
<span class="show-more-icon-btn"></span> <!-- use on small screen to display hidden toolbar buttons -->
</div>
<% e.begin_block("afterEditbar"); %><% e.end_block(); %>
<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">
<% e.begin_block("permissionDenied"); %>
<div id="permissionDenied">
<p data-l10n-id="pad.permissionDenied" class="editorloadingbox-message">
You do not have permission to access this pad
</p>
</div>
<% e.end_block(); %>
<% e.begin_block("loading"); %>
<p data-l10n-id="pad.loading" id="loading" class="editorloadingbox-message">
<img src='../static/img/brand.svg' class='etherpadBrand'><br/>
Loading...
<div id="editorloadingbox">
<% e.begin_block("permissionDenied"); %>
<div id="permissionDenied">
<p data-l10n-id="pad.permissionDenied" class="editorloadingbox-message">
You do not have permission to access this pad
</p>
</div>
<% e.end_block(); %>
<% e.begin_block("loading"); %>
<p data-l10n-id="pad.loading" id="loading" class="editorloadingbox-message">
<img src='../static/img/brand.svg' class='etherpadBrand'><br/>
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>
<% 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>
<!------------------------------------------------------------->
<!-- SETTINGS POPUP (change font, language, chat parameters) -->
<!------------------------------------------------------------->
<div id="settings" class="popup"><div class="popup-content">
<h1 data-l10n-id="pad.settings.padSettings"></h1>
<% e.begin_block("mySettings"); %>
<h2 data-l10n-id="pad.settings.myView"></h2>
<p class="hide-for-mobile">
<input type="checkbox" id="options-stickychat" onClick="chat.stickToScreen();">
<label for="options-stickychat" data-l10n-id="pad.settings.stickychat"></label>
</p>
<p class="hide-for-mobile">
<input type="checkbox" id="options-chatandusers" onClick="chat.chatAndUsers();">
<label for="options-chatandusers" data-l10n-id="pad.settings.chatandusers"></label>
</p>
<p>
<input type="checkbox" id="options-colorscheck">
<label for="options-colorscheck" data-l10n-id="pad.settings.colorcheck"></label>
</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 class="dropdown-line">
<label for="languagemenu" data-l10n-id="pad.settings.language">Language:</label>
<select id="languagemenu">
<% for (lang in langs) { %>
<option value="<%=lang%>"><%=langs[lang].nativeName%></option>
<% } %>
</select>
</p>
<% e.end_block(); %>
</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>
<h2 data-l10n-id="pad.settings.about">About</h2>
<span data-l10n-id="pad.settings.poweredBy">Powered by</span>
<a href="https://etherpad.org">Etherpad</a>
<% if (settings.exposeVersion) { %>(commit <%=settings.getGitCommit()%>)<% } %>
</div>
</div>
<p class="dropdown-line">
<label for="languagemenu" data-l10n-id="pad.settings.language">Language:</label>
<select id="languagemenu">
<% for (lang in langs) { %>
<option value="<%=lang%>"><%=langs[lang].nativeName%></option>
<% } %>
</select>
</p>
<% e.end_block(); %>
</div>
<!------------------------->
<!-- IMPORT EXPORT POPUP -->
<!------------------------->
<h2 data-l10n-id="pad.settings.about">About</h2>
<span data-l10n-id="pad.settings.poweredBy">Powered by</span>
<a href="https://etherpad.org">Etherpad</a>
<% if (settings.exposeVersion) { %>(commit <%=settings.getGitCommit()%>)<% } %>
</div></div>
<!------------------------->
<!-- IMPORT EXPORT POPUP -->
<!------------------------->
<div id="import_export" class="popup"><div class="popup-content">
<h1 data-l10n-id="pad.importExport.import_export"></h1>
<div class="acl-write">
<% e.begin_block("importColumn"); %>
<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">
<div class="importformdiv" id="importformfilediv">
<input type="file" name="file" size="10" id="importfileinput">
<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">
<div id="import_export" class="popup">
<div class="popup-content">
<h1 data-l10n-id="pad.importExport.import_export"></h1>
<div class="acl-write">
<% e.begin_block("importColumn"); %>
<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">
<div class="importformdiv" id="importformfilediv">
<input type="file" name="file" size="10" id="importfileinput">
<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>
<!-------------------------------->
<!-- 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 id="chatbox">
<div class="chat-content">
<div id="titlebar">
<h1 id ="titlelabel" data-l10n-id="pad.chat"></h1>
<a id="titlecross" class="hide-reduce-btn" onClick="chat.hide();return false;">-&nbsp;</a>
<a id="titlesticky" class="stick-to-screen-btn" onClick="chat.stickToScreen(true);return false;" data-l10n-id="pad.chat.stick.title">&nbsp;&nbsp;</a>
</div>
<div id="chattext" class="thin-scrollbar" aria-live="polite" aria-relevant="additions removals text" role="log" aria-atomic="false">
<div alt="loading.." id="chatloadmessagesball" class="chatloadmessages loadingAnimation" align="top"></div>
<button id="chatloadmessagesbutton" class="chatloadmessages" data-l10n-id="pad.chat.loadmessages"></button>
</div>
<div id="chatinputbox">
<form>
<textarea id="chatinput" maxlength="999" data-l10n-id="pad.chat.writeMessage.placeholder"></textarea>
</form>
</div>
</div>
<div 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>
<!------------------------------------------------------------------>
<!-- SKIN VARIANTS BUILDER (Customize rendering, only for admins) -->
<!------------------------------------------------------------------>
<% if (settings.skinName == 'colibris') { %>
<div id="skin-variants" class="popup"><div class="popup-content">
<!---------------------------------------------------->
<!-- 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>
<% 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>
<div class="dropdowns-container">
<% containers = [ "toolbar", "background", "editor" ]; %>
<% for(var i=0; i < containers.length; i++) { %>
<% containers = [ "toolbar", "background", "editor" ]; %>
<% for(var i=0; i < containers.length; i++) { %>
<p class="dropdown-line">
<label class="skin-variant-container"><%=containers[i]%></label>
<select class="skin-variant skin-variant-color" data-container="<%=containers[i]%>">
@ -413,28 +386,28 @@
<option value="super-dark">Super Dark</option>
</select>
</p>
<% } %>
<% } %>
</div>
<p>
<input type="checkbox" id="skin-variant-full-width" class="skin-variant"/>
<label for="skin-variant-full-width">Full Width Editor</label>
<input type="checkbox" id="skin-variant-full-width" class="skin-variant"/>
<label for="skin-variant-full-width">Full Width Editor</label>
</p>
<p>
<label>Result to copy in settings.json</label>
<input id="skin-variants-result" type="text" readonly class="disabled" />
</p>
</div></div>
<% } %>
</div>
</div>
<% } %>
<% e.end_block(); %>
<% e.end_block(); %>
</div> <!-- End of #editorcontainerbox -->
<% e.end_block(); %>
<!----------------------------->
<!-------- JAVASCRIPT --------->
<!----------------------------->
@ -454,53 +427,62 @@
<!-- Bootstrap page -->
<script type="text/javascript">
// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt
var clientVars = {
// This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the
// server sends the CLIENT_VARS message.
randomVersionString: <%-JSON.stringify(settings.randomVersionString)%>,
};
(function () {
var pathComponents = location.pathname.split('/');
// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt
var clientVars = {
// This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the
// server sends the CLIENT_VARS message.
randomVersionString: <%-JSON.stringify(settings.randomVersionString)%>,
};
(function () {
var pathComponents = location.pathname.split('/');
// Strip 'p' and the padname from the pathname and set as baseURL
var baseURL = pathComponents.slice(0,pathComponents.length-2).join('/') + '/';
// Strip 'p' and the padname from the pathname and set as baseURL
var baseURL = pathComponents.slice(0,pathComponents.length-2).join('/') + '/';
require.setRootURI(baseURL + "javascripts/src");
require.setLibraryURI(baseURL + "javascripts/lib");
require.setGlobalKeyPath("require");
require.setRootURI(baseURL + "javascripts/src");
require.setLibraryURI(baseURL + "javascripts/lib");
require.setGlobalKeyPath("require");
$ = jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery; // Expose jQuery #HACK
browser = require('ep_etherpad-lite/static/js/vendors/browser');
$ = jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery; // Expose jQuery #HACK
browser = require('ep_etherpad-lite/static/js/vendors/browser');
var plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins');
var hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks');
var plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins');
var hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks');
plugins.baseURL = baseURL;
plugins.update(function () {
// Mechanism for tests to register hook functions (install fake plugins).
window._postPluginUpdateForTestingDone = false;
if (window._postPluginUpdateForTesting != null) window._postPluginUpdateForTesting();
window._postPluginUpdateForTestingDone = true;
// Call documentReady hook
$(function() {
hooks.aCallAll('documentReady');
});
var pad = require('ep_etherpad-lite/static/js/pad');
pad.baseURL = baseURL;
pad.init();
plugins.baseURL = baseURL;
plugins.update(function () {
// Mechanism for tests to register hook functions (install fake plugins).
window._postPluginUpdateForTestingDone = false;
if (window._postPluginUpdateForTesting != null) window._postPluginUpdateForTesting();
window._postPluginUpdateForTestingDone = true;
// Call documentReady hook
$(function() {
hooks.aCallAll('documentReady');
});
/* TODO: These globals shouldn't exist. */
pad = require('ep_etherpad-lite/static/js/pad').pad;
chat = require('ep_etherpad-lite/static/js/chat').chat;
padeditbar = require('ep_etherpad-lite/static/js/pad_editbar').padeditbar;
padimpexp = require('ep_etherpad-lite/static/js/pad_impexp').padimpexp;
require('ep_etherpad-lite/static/js/skin_variants');
var pad = require('ep_etherpad-lite/static/js/pad');
pad.baseURL = baseURL;
pad.init();
});
}());
// @license-end
/* TODO: These globals shouldn't exist. */
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>
<div style="display:none"><a href="/javascript" data-jslicense="1">JavaScript license information</a></div>
<% e.end_block(); %>

View File

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

View File

@ -6,155 +6,185 @@ const assert = require('assert').strict;
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const pluginDefs = require('../../../static/js/pluginfw/plugin_defs');
const plugins = require('../../../static/js/pluginfw/plugins');
const settings = require('../../../node/utils/Settings');
const logger = common.logger;
const checkHook = async (hookName, checkFn) => {
if (pluginDefs.hooks[hookName] == null) pluginDefs.hooks[hookName] = [];
await new Promise((resolve, reject) => {
pluginDefs.hooks[hookName].push({
hook_fn: async (hookName, context) => {
if (checkFn == null) return;
logger.debug(`hook ${hookName} invoked`);
try {
// Make sure checkFn is called only once.
const _checkFn = checkFn;
checkFn = null;
await _checkFn(context);
} catch (err) {
reject(err);
return;
}
resolve();
},
let hook;
try {
await new Promise((resolve, reject) => {
hook = {
hook_fn: async (hookName, context) => {
if (checkFn == null) return;
logger.debug(`hook ${hookName} invoked`);
try {
// Make sure checkFn is called only once.
const _checkFn = checkFn;
checkFn = null;
await _checkFn(context);
} catch (err) {
reject(err);
return;
}
resolve();
},
};
pluginDefs.hooks[hookName].push(hook);
});
});
} finally {
pluginDefs.hooks[hookName] = pluginDefs.hooks[hookName].filter((h) => h !== hook);
}
};
const sendMessage = (socket, data) => {
socket.send({
type: 'COLLABROOM',
component: 'pad',
data,
});
};
const sendChat = (socket, message) => sendMessage(socket, {type: 'CHAT_MESSAGE', message});
const sendMessage = async (socket, data) => (
await common.sendMessage(socket, {type: 'COLLABROOM', component: 'pad', data}));
const sendChat = async (socket, message) => (
await sendMessage(socket, {type: 'CHAT_MESSAGE', message}));
describe(__filename, function () {
const backups = {settings: {}};
let clientVars;
const padId = 'testChatPad';
const hooksBackup = {};
let socket;
const connect = async () => {
socket = await common.connect();
({data: clientVars} = await common.handshake(socket, padId));
};
before(async function () {
for (const [name, defs] of Object.entries(pluginDefs.hooks)) {
if (defs == null) continue;
hooksBackup[name] = defs;
}
backups.settings.integratedChat = settings.integratedChat;
});
beforeEach(async function () {
for (const [name, defs] of Object.entries(hooksBackup)) pluginDefs.hooks[name] = [...defs];
for (const name of Object.keys(pluginDefs.hooks)) {
if (hooksBackup[name] == null) delete pluginDefs.hooks[name];
}
if (await padManager.doesPadExist(padId)) {
const pad = await padManager.getPad(padId);
await pad.remove();
}
});
after(async function () {
Object.assign(pluginDefs.hooks, hooksBackup);
for (const name of Object.keys(pluginDefs.hooks)) {
if (hooksBackup[name] == null) delete pluginDefs.hooks[name];
afterEach(async function () {
if (socket) {
socket.close();
socket = null;
}
});
describe('chatNewMessage hook', function () {
let authorId;
let socket;
after(async function () {
Object.assign(settings, backups.settings);
await plugins.update();
});
describe('settings.integratedChat = true', function () {
before(async function () {
settings.integratedChat = true;
await plugins.update();
});
beforeEach(async function () {
socket = await common.connect();
const {data: clientVars} = await common.handshake(socket, padId);
authorId = clientVars.userId;
await connect();
});
afterEach(async function () {
socket.close();
});
it('message', async function () {
const start = Date.now();
await Promise.all([
checkHook('chatNewMessage', ({message}) => {
assert(message != null);
assert(message instanceof ChatMessage);
assert.equal(message.authorId, authorId);
assert.equal(message.text, this.test.title);
assert(message.time >= start);
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);
describe('chatNewMessage hook', function () {
it('message', async function () {
const start = Date.now();
await Promise.all([
checkHook('chatNewMessage', ({message}) => {
assert(message != null);
assert(message instanceof ChatMessage);
assert.equal(message.authorId, clientVars.userId);
assert.equal(message.text, this.test.title);
assert(message.time >= start);
assert(message.time <= Date.now());
}),
sendChat(socket, {text: this.test.title}),
]);
});
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}),
]);
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>`;
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($userSwatch.css('background-color')).to.be(testColorRGB);
});
it('Own user color is shown when you enter a chat', function (done) {
this.timeout(1000);
const chrome$ = helper.padChrome$;
const $colorOption = helper.padChrome$('#options-colorscheck');
if (!$colorOption.is(':checked')) {
$colorOption.click();
}
// click on the settings button to make settings visible
const $userButton = chrome$('.buttonicon-showusers');
$userButton.click();
const $userSwatch = chrome$('#myswatch');
$userSwatch.click();
const fb = chrome$.farbtastic('#colorpicker');
const $colorPickerSave = chrome$('#mycolorpickersave');
// Same color represented in two different ways
const testColorHash = '#abcdef';
const testColorRGB = 'rgb(171, 205, 239)';
fb.setColor(testColorHash);
$colorPickerSave.click();
// click on the chat button to make chat visible
const $chatButton = chrome$('#chaticon');
$chatButton.click();
const $chatInput = chrome$('#chatinput');
$chatInput.sendkeys('O hi'); // simulate a keypress of typing user
$chatInput.sendkeys('{enter}');
// wait until the chat message shows up
helper.waitFor(() => chrome$('#chattext').children('p').length !== 0).done(() => {
const $firstChatMessage = chrome$('#chattext').children('p');
// expect the first chat message to be of the user's color
expect($firstChatMessage.css('background-color')).to.be(testColorRGB);
done();
});
});
});

View File

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

View File

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