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