diff --git a/client/client.js b/client/client.js
index d333178..0bebc59 100644
--- a/client/client.js
+++ b/client/client.js
@@ -15,7 +15,7 @@ var markdownOptions = {
langPrefix: '',
linkify: true,
linkTarget: '_blank" rel="noreferrer',
- typographer: true,
+ typographer: true,
quotes: `""''`,
doHighlight: true,
@@ -25,12 +25,12 @@ var markdownOptions = {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(lang, str).value;
- } catch (__) {}
+ } catch (__) { }
}
try {
return hljs.highlightAuto(str).value;
- } catch (__) {}
+ } catch (__) { }
return '';
}
@@ -67,20 +67,20 @@ md.renderer.rules.image = function (tokens, idx, options) {
return '';
}
- return '' + Remarkable.utils.escapeHtml(Remarkable.utils.replaceEntities(src)) + '';
+ return '' + Remarkable.utils.escapeHtml(Remarkable.utils.replaceEntities(src)) + '';
};
md.renderer.rules.link_open = function (tokens, idx, options) {
var title = tokens[idx].title ? (' title="' + Remarkable.utils.escapeHtml(Remarkable.utils.replaceEntities(tokens[idx].title)) + '"') : '';
- var target = options.linkTarget ? (' target="' + options.linkTarget + '"') : '';
- return '';
+ var target = options.linkTarget ? (' target="' + options.linkTarget + '"') : '';
+ return '';
};
-md.renderer.rules.text = function(tokens, idx) {
+md.renderer.rules.text = function (tokens, idx) {
tokens[idx].content = Remarkable.utils.escapeHtml(tokens[idx].content);
if (tokens[idx].content.indexOf('?') !== -1) {
- tokens[idx].content = tokens[idx].content.replace(/(^|\s)(\?)\S+?(?=[,.!?:)]?\s|$)/gm, function(match) {
+ tokens[idx].content = tokens[idx].content.replace(/(^|\s)(\?)\S+?(?=[,.!?:)]?\s|$)/gm, function (match) {
var channelLink = Remarkable.utils.escapeHtml(Remarkable.utils.replaceEntities(match.trim()));
var whiteSpace = '';
if (match[0] !== '?') {
@@ -90,7 +90,7 @@ md.renderer.rules.text = function(tokens, idx) {
});
}
- return tokens[idx].content;
+ return tokens[idx].content;
};
md.use(remarkableKatex);
@@ -164,6 +164,34 @@ var myChannel = window.location.search.replace(/^\?/, '');
var lastSent = [""];
var lastSentPos = 0;
+/**
+ * Stores active messages
+ * These are messages that can be edited.
+ * @type {{ customId: string, userid: number, sent: number, text: string, elem: HTMLElement }[]}
+ */
+var activeMessages = [];
+
+setInterval(function () {
+ var editTimeout = 6 * 60 * 1000;
+ var now = Date.now();
+ for (var i = 0; i < activeMessages.length; i++) {
+ if (now - activeMessages[i].sent > editTimeout) {
+ activeMessages.splice(i, 1);
+ i--;
+ }
+ }
+}, 30 * 1000);
+
+function addActiveMessage(customId, userid, text, elem) {
+ activeMessages.push({
+ customId,
+ userid,
+ sent: Date.now(),
+ text,
+ elem,
+ });
+}
+
/** Notification switch and local storage behavior **/
var notifySwitch = document.getElementById("notify-switch")
var notifySetting = localStorageGet("notify-api")
@@ -364,7 +392,59 @@ var COMMANDS = {
if (ignoredUsers.indexOf(args.nick) >= 0) {
return;
}
- pushMessage(args);
+
+ var elem = pushMessage(args);
+
+ if (typeof (args.customId) === 'string') {
+ addActiveMessage(args.customId, args.userid, args.text, elem);
+ }
+ },
+
+ updateMessage: function (args) {
+ var customId = args.customId;
+ var mode = args.mode;
+
+ if (!mode) {
+ return;
+ }
+
+ var message;
+ for (var i = 0; i < activeMessages.length; i++) {
+ var msg = activeMessages[i];
+ if (msg.userid === args.userid && msg.customId === customId) {
+ message = msg;
+ break;
+ }
+ }
+
+ if (!message) {
+ return;
+ }
+
+ var textElem = message.elem.querySelector('.text');
+ if (!textElem) {
+ return;
+ }
+
+ var newText = message.text;
+ if (mode === 'overwrite') {
+ newText = args.text;
+ } else if (mode === 'append') {
+ newText += args.text;
+ } else if (mode === 'prepend') {
+ newText = args.text + newText;
+ }
+
+ message.text = newText;
+
+ // Scroll to bottom if necessary
+ var atBottom = isAtBottom();
+
+ textElem.innerHTML = md.render(newText);
+
+ if (atBottom) {
+ window.scrollTo(0, document.body.scrollHeight);
+ }
},
info: function (args) {
@@ -518,6 +598,8 @@ function pushMessage(args) {
unread += 1;
updateTitle();
+
+ return messageEl;
}
function insertAtCursor(text) {
@@ -705,8 +787,8 @@ $('#sidebar').onmouseleave = document.ontouchstart = function (event) {
var e = event.toElement || event.relatedTarget;
try {
if (e.parentNode == this || e == this) {
- return;
- }
+ return;
+ }
} catch (e) { return; }
if (!$('#pin-sidebar').checked) {
@@ -734,8 +816,8 @@ if (localStorageGet('joined-left') == 'false') {
if (localStorageGet('parse-latex') == 'false') {
$('#parse-latex').checked = false;
- md.inline.ruler.disable([ 'katex' ]);
- md.block.ruler.disable([ 'katex' ]);
+ md.inline.ruler.disable(['katex']);
+ md.block.ruler.disable(['katex']);
}
$('#pin-sidebar').onchange = function (e) {
@@ -750,11 +832,11 @@ $('#parse-latex').onchange = function (e) {
var enabled = !!e.target.checked;
localStorageSet('parse-latex', enabled);
if (enabled) {
- md.inline.ruler.enable([ 'katex' ]);
- md.block.ruler.enable([ 'katex' ]);
+ md.inline.ruler.enable(['katex']);
+ md.block.ruler.enable(['katex']);
} else {
- md.inline.ruler.disable([ 'katex' ]);
- md.block.ruler.disable([ 'katex' ]);
+ md.inline.ruler.disable(['katex']);
+ md.block.ruler.disable(['katex']);
}
}
diff --git a/commands/core/chat.js b/commands/core/chat.js
index fdd8eb9..8c4cca1 100644
--- a/commands/core/chat.js
+++ b/commands/core/chat.js
@@ -6,33 +6,64 @@
* @module chat
*/
+import { parseText } from '../utility/_Text.js';
import {
isAdmin,
isModerator,
} from '../utility/_UAC.js';
+export const MAX_MESSAGE_ID_LENGTH = 6;
/**
- * Check and trim string provided by remote client
- * @param {string} text - Subject string
- * @private
- * @todo Move into utility module
- * @return {string|boolean}
- */
-const parseText = (text) => {
- // verifies user input is text
- if (typeof text !== 'string') {
- return false;
+ * The time in milliseconds before a message is considered stale, and thus no longer allowed
+ * to be edited.
+ * @type {number}
+ */
+const ACTIVE_TIMEOUT = 5 * 60 * 1000;
+/**
+ * The time in milliseconds that a check for stale messages should be performed.
+ * @type {number}
+ */
+const TIMEOUT_CHECK_INTERVAL = 30 * 1000;
+
+/**
+ * Stores active messages that can be edited.
+ * @type {{ customId: string, userid: number, sent: number }[]}
+ */
+export const ACTIVE_MESSAGES = [];
+
+/**
+ * Cleans up stale messages.
+ * @public
+ * @return {void}
+ */
+export function cleanActiveMessages() {
+ const now = Date.now();
+ for (let i = 0; i < ACTIVE_MESSAGES.length; i++) {
+ const message = ACTIVE_MESSAGES[i];
+ if (now - message.sent > ACTIVE_TIMEOUT) {
+ ACTIVE_MESSAGES.splice(i, 1);
+ i--;
+ }
}
+}
- let sanitizedText = text;
+// TODO: This won't get cleared on module reload.
+setInterval(cleanActiveMessages, TIMEOUT_CHECK_INTERVAL);
- // strip newlines from beginning and end
- sanitizedText = sanitizedText.replace(/^\s*\n|^\s+$|\n\s*$/g, '');
- // replace 3+ newlines with just 2 newlines
- sanitizedText = sanitizedText.replace(/\n{3,}/g, '\n\n');
-
- return sanitizedText;
-};
+/**
+ * Adds a message to the active messages map.
+ * @public
+ * @param {string} id
+ * @param {number} userid
+ * @return {void}
+ */
+export function addActiveMessage(customId, userid) {
+ ACTIVE_MESSAGES.push({
+ customId,
+ userid,
+ sent: Date.now(),
+ });
+}
/**
* Executes when invoked by a remote client
@@ -61,6 +92,13 @@ export async function run({
}, socket);
}
+ const customId = payload.customId;
+
+ if (typeof (customId) === 'string' && customId.length > MAX_MESSAGE_ID_LENGTH) {
+ // There's a limit on the custom id length.
+ return server.police.frisk(socket.address, 13);
+ }
+
// build chat payload
const outgoingPayload = {
cmd: 'chat',
@@ -70,6 +108,7 @@ export async function run({
channel: socket.channel,
text,
level: socket.level,
+ customId,
};
if (isAdmin(socket.level)) {
@@ -86,6 +125,7 @@ export async function run({
outgoingPayload.color = socket.color;
}
+ addActiveMessage(outgoingPayload.customId, socket.userid);
// broadcast to channel peers
server.broadcast(outgoingPayload, { channel: socket.channel });
diff --git a/commands/core/updateMessage.js b/commands/core/updateMessage.js
new file mode 100644
index 0000000..564a936
--- /dev/null
+++ b/commands/core/updateMessage.js
@@ -0,0 +1,97 @@
+import { parseText } from "../utility/_Text.js";
+import { isAdmin, isModerator } from "../utility/_UAC.js";
+import { ACTIVE_MESSAGES, MAX_MESSAGE_ID_LENGTH } from "./chat.js";
+
+export async function run({ core, server, socket, payload }) {
+ // undefined | "overwrite" | "append" | "prepend"
+ let mode = payload.mode;
+
+ if (!mode) {
+ mode = 'overwrite';
+ }
+
+ if (mode !== 'overwrite' && mode !== 'append' && mode !== 'prepend') {
+ return server.police.frisk(socket.address, 13);
+ }
+
+ const customId = payload.customId;
+
+ if (!customId || typeof customId !== "string" || customId.length > MAX_MESSAGE_ID_LENGTH) {
+ return server.police.frisk(socket.address, 13);
+ }
+
+ let text = payload.text;
+
+ if (typeof (text) !== 'string') {
+ return server.police.frisk(socket.address, 13);
+ }
+
+ if (mode === 'overwrite') {
+ text = parseText(text);
+
+ if (text === '') {
+ text = '\u0000';
+ }
+ }
+
+ if (!text) {
+ return server.police.frisk(socket.address, 13);
+ }
+
+ // TODO: What score should we use for this? It isn't as space filling as chat messages.
+ // But we also don't want a massive growing message.
+ // Or flashing between huge and small. Etc.
+
+ let message;
+ for (let i = 0; i < ACTIVE_MESSAGES.length; i++) {
+ const msg = ACTIVE_MESSAGES[i];
+
+ if (msg.userid === socket.userid && msg.customId === customId) {
+ message = ACTIVE_MESSAGES[i];
+ break;
+ }
+ }
+
+ if (!message) {
+ return server.police.frisk(socket.address, 6);
+ }
+
+ const outgoingPayload = {
+ cmd: 'updateMessage',
+ userid: socket.userid,
+ channel: socket.channel,
+ level: socket.level,
+ mode,
+ text,
+ customId: message.customId,
+ };
+
+ if (isAdmin(socket.level)) {
+ outgoingPayload.admin = true;
+ } else if (isModerator(socket.level)) {
+ outgoingPayload.mod = true;
+ }
+
+ server.broadcast(outgoingPayload, { channel: socket.channel });
+
+ return true;
+}
+
+export const requiredData = ['text', 'customId'];
+
+/**
+ * Module meta information
+ * @public
+ * @typedef {Object} updateMessage/info
+ * @property {string} name - Module command name
+ * @property {string} category - Module category name
+ * @property {string} description - Information about module
+ * @property {string} usage - Information about module usage
+ */
+export const info = {
+ name: 'updateMessage',
+ category: 'core',
+ description: 'Update a message you have sent.',
+ usage: `
+ API: { cmd: 'updateMessage', mode: 'overwrite'|'append'|'prepand', text: '',customId: '' }`,
+};
diff --git a/commands/utility/_Text.js b/commands/utility/_Text.js
new file mode 100644
index 0000000..05939da
--- /dev/null
+++ b/commands/utility/_Text.js
@@ -0,0 +1,21 @@
+/**
+ * Check and trim string provided by remote client
+ * @public
+ * @param {string} text - Subject string
+ * @return {string|null}
+ */
+export const parseText = (text) => {
+ // verifies user input is text
+ if (typeof text !== 'string') {
+ return null;
+ }
+
+ let sanitizedText = text;
+
+ // strip newlines from beginning and end
+ sanitizedText = sanitizedText.replace(/^\s*\n|^\s+$|\n\s*$/g, '');
+ // replace 3+ newlines with just 2 newlines
+ sanitizedText = sanitizedText.replace(/\n{3,}/g, '\n\n');
+
+ return sanitizedText;
+};