diff --git a/.eslintrc.json b/.eslintrc.json index c048b97b0f..890eb37b4c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -97,7 +97,7 @@ ], "lines-around-comment": "off", "lines-around-directive": "off", - "max-depth": "error", + "max-depth": "off", "max-len": "off", "max-lines": "off", "max-nested-callbacks": "off", @@ -192,7 +192,7 @@ "no-undef-init": "error", "no-undefined": "off", "no-underscore-dangle": "off", - "no-unmodified-loop-condition": "error", + "no-unmodified-loop-condition": "off", "no-unneeded-ternary": "off", "no-unused-vars": "error", "no-unused-expressions": "off", diff --git a/CHANGES.md b/CHANGES.md index 8b22324b5c..085e9c807a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,8 @@ configuration settings should now be accessed via `_converse.api.settings.get` and not directly on the `_converse` object. Soon we'll deprecate the latter, so prepare now. +- #515 Add support for XEP-0050 Ad-Hoc commands +- #1083 Add support for XEP-0393 Message Styling - #2231: add sort_by_query and remove sort_by_length - #1313: Stylistic improvements to the send button - #1481: MUC OMEMO: Error No record for device @@ -15,7 +17,6 @@ Soon we'll deprecate the latter, so prepare now. - #1793: Send button doesn't appear in Firefox in 1:1 chats - #1820: Set focus on jid field after controlbox is loaded - #1822: Don't log error if user has no bookmarks -- #515 Add support for XEP-0050 Ad-Hoc commands - #1823: New config options [muc_roomid_policy](https://conversejs.org/docs/html/configuration.html#muc-roomid-policy) and [muc_roomid_policy_hint](https://conversejs.org/docs/html/configuration.html#muc-roomid-policy-hint) - #1826: A user can now add himself as a contact diff --git a/README.md b/README.md index d4a9dc8a44..36e29afb3c 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ In embedded mode, Converse can be embedded into an element in the DOM. - [XEP-0372](https://xmpp.org/extensions/xep-0372.html) References - [XEP-0382](https://xmpp.org/extensions/xep-0382.html) Spoiler messages - [XEP-0384](https://xmpp.org/extensions/xep-0384.html) OMEMO Encryption +- [XEP-0393](https://xmpp.org/extensions/xep-0393.html) Message styling - [XEP-0422](https://xmpp.org/extensions/xep-0422.html) Message Fastening (limited support) - [XEP-0424](https://xmpp.org/extensions/xep-0424.html) Message Retractions - [XEP-0425](https://xmpp.org/extensions/xep-0425.html) Message Moderation diff --git a/index.html b/index.html index f4a73102a6..53e71404f6 100644 --- a/index.html +++ b/index.html @@ -197,6 +197,7 @@

Features

  • Hidden messages (aka Spoilers) (XEP 382)
  • Client state indication (XEP 352)
  • OMEMO encrypted messaging (XEP 384)
  • +
  • Message Styling (XEP 393)
  • Anonymous logins, see the anonymous login demo
  • Message corrections, retractions and moderation
  • Translated into over 30 languages
  • diff --git a/karma.conf.js b/karma.conf.js index 56d2745c84..036c493b86 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -47,6 +47,7 @@ module.exports = function(config) { { pattern: "spec/user-details-modal.js", type: 'module' }, { pattern: "spec/messages.js", type: 'module' }, { pattern: "spec/corrections.js", type: 'module' }, + { pattern: "spec/message-styling.js", type: 'module' }, { pattern: "spec/receipts.js", type: 'module' }, { pattern: "spec/muc_messages.js", type: 'module' }, { pattern: "spec/mentions.js", type: 'module' }, diff --git a/package-lock.json b/package-lock.json index f482a17f22..ccccc61b84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3144,7 +3144,8 @@ "dependencies": { "filesize": { "version": "6.1.0", - "resolved": false + "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz", + "integrity": "sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg==" }, "fs-extra": { "version": "8.1.0", @@ -3200,20 +3201,22 @@ }, "localforage": { "version": "1.7.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.7.3.tgz", + "integrity": "sha512-1TulyYfc4udS7ECSBT2vwJksWbkwwTX8BzeUIiq8Y07Riy7bDAAnxDaPU/tWyOVmQAcWJIEIFP9lPfBGqVoPgQ==", "requires": { "lie": "3.1.1" } }, "pluggable.js": { "version": "2.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/pluggable.js/-/pluggable.js-2.0.1.tgz", + "integrity": "sha512-SBt6v6Tbp20Jf8hU0cpcc/+HBHGMY8/Q+yA6Ih0tBQE8tfdZ6U4PRG0iNvUUjLx/hVyOP53n0UfGBymlfaaXCg==", "requires": { "lodash": "^4.17.11" } }, "skeletor.js": { - "version": "0.0.1", + "version": "github:skeletorjs/skeletor#bf6d9c86f9fcf224fa9d9af5a25380b77aa4b561", "from": "github:skeletorjs/skeletor#bf6d9c86f9fcf224fa9d9af5a25380b77aa4b561", "requires": { "lodash": "^4.17.14" @@ -3221,7 +3224,11 @@ }, "strophe.js": { "version": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f", - "from": "strophe.js@github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f" + "from": "strophe.js@github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f", + "requires": { + "abab": "^2.0.3", + "xmldom": "^0.1.27" + } }, "twemoji": { "version": "12.1.5", diff --git a/sass/_messages.scss b/sass/_messages.scss index 12ebf33cf6..ea83191b65 100644 --- a/sass/_messages.scss +++ b/sass/_messages.scss @@ -5,6 +5,20 @@ } } .message { + blockquote { + margin-left: 0.5em; + margin-bottom: 0.25em; + padding-right: 1em; + color: var(--subdued-color); + border-left: 0.3em solid var(--subdued-color); + padding-left: 0.5em; + } + code { + font-family: monospace; + &.block { + display: block; + } + } .mention { font-weight: bold; } diff --git a/spec/message-styling.js b/spec/message-styling.js new file mode 100644 index 0000000000..48dd52694e --- /dev/null +++ b/spec/message-styling.js @@ -0,0 +1,107 @@ +/*global mock, converse */ + +const u = converse.env.u; + +describe("A Chat Message", function () { + + fit("can be styled with XEP-0393 message styling hints", + mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + let msg_text, msg, msg_el; + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.api.chatviews.get(contact_jid); + + msg_text = "This *message _contains_* styling hints! \`Here's *some* code\`"; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + msg_el = view.el.querySelector('converse-chat-message-body'); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(//g, '') === + 'This *message _contains_* styling hints! `Here\'s *some* code`'); + + msg_text = "This *is not a styling hint \n * _But this is_!"; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + msg_el = Array.from(view.el.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(//g, '') === + "This *is not a styling hint \n * _But this is_!"); + + msg_text = "Here's a ~strikethrough section~"; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + msg_el = Array.from(view.el.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(//g, '') === + "Here's a ~strikethrough section~"); + + msg_text = `Here's a code block: \`\`\`\nInside the code-block, hello we don't enable *styling hints* like ~these~\n\`\`\``; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + msg_el = Array.from(view.el.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(//g, '') === + 'Here\'s a code block: ```\nInside the code-block, <code>hello</code> we don\'t enable *styling hints* like ~these~\n```' + ); + + msg_text = `> This is quoted text\n>This is also quoted\nThis is not quoted`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + msg_el = Array.from(view.el.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(//g, '') === '
    This is quoted text\nThis is also quoted\n
    This is not quoted'); + + msg_text = `> This is *quoted* text\n>This is \`also _quoted_\`\nThis is not quoted`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + msg_el = Array.from(view.el.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(//g, '') === + "
    This is *quoted* text\nThis is `also _quoted_`\n
    This is not quoted"); + + msg_text = `(There are three blocks in this body marked by parens,)\n (but there is no *formatting)\n (as spans* may not escape blocks.)`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + msg_el = Array.from(view.el.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(//g, '') === msg_text); + + msg_text = "```ignored\n (println \"Hello, world!\")\n ```\n\n This should show up as monospace, preformatted text ^"; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + msg_el = Array.from(view.el.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(//g, '') === + "```ignored\n (println \"Hello, world!\")\n ```\n\n This should show up as monospace, preformatted text ^"); + + msg_text = ">```ignored\n> (println \"Hello, world!\")\n> ```\n>\n> This should show up as monospace, preformatted text ^"; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + msg_el = Array.from(view.el.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(//g, '') === + "
    ```ignored\n <span></span> (println \"Hello, world!\")\n ```\n\n This should show up as monospace, preformatted text ^
    "); + + msg_text = `> > This is doubly quoted text`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + msg_el = Array.from(view.el.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(//g, '') === "
    This is doubly quoted text
    "); + + done(); + })); +}); diff --git a/src/headless/utils/parse-helpers.js b/src/headless/utils/parse-helpers.js index 8036f3e537..d3813280be 100644 --- a/src/headless/utils/parse-helpers.js +++ b/src/headless/utils/parse-helpers.js @@ -4,6 +4,7 @@ * @description Pure functions to help funcitonally parse messages. * @todo Other parsing helpers can be made more abstract and placed here. */ + const helpers = {}; // Captures all mentions, but includes a space before the @ @@ -41,3 +42,144 @@ const reduceReferences = ([text, refs], ref, index) => { helpers.reduceTextFromReferences = (text, refs) => refs.reduce(reduceReferences, [text, []]); export default helpers; + +const styling_directives = ['*', '_', '~', '`', '```', '>', '>']; +const recursive_directives = ['*', '_', '~', '>', '>']; +const styling_map = { + '*': {'name': 'strong', 'type': 'span'}, + '_': {'name': 'emphasis', 'type': 'span'}, + '~': {'name': 'strike', 'type': 'span'}, + '`': {'name': 'preformatted', 'type': 'span'}, + '```': {'name': 'preformatted_block', 'type': 'block'}, + '>': {'name': 'quote', 'type': 'block'}, + '>': {'name': 'quote', 'type': 'block'} +}; + +const styling_templates = { + emphasis: (text) => `${text}`, + preformatted: (text) => `${text}`, + preformatted_block: (text) => `${text}`, + quote: (text) => `
    ${text}
    `, + strike: (text) => `${text}`, + strong: (text) => `${text}`, +}; + +const isQuoteDirective = (d) => ['>', '>'].includes(d); + +function escape (text) { + return text + .replace(/\&/g, "&") + .replace(//g, "$1>") + .replace(/'/g, "'") + .replace(/"/g, """); +} + + +function getDirective (text, i) { + // TODO: blockquote is only valid if on own line + // TODO: blockquote without end quote is valid until end of text or of containing quote + let d; + if (styling_directives.includes(text.slice(i, i+4))) { + d = text.slice(i, i+4); + } else if (styling_directives.includes(text.slice(i, i+3))) { + d = text.slice(i, i+3); + } else if (styling_directives.includes(text.slice(i, i+1))) { + d = text.slice(i, i+1); + } else { + return null; + } + if (styling_map[d].type === 'span' && !text.slice(i+1).split('\n').shift().includes(d)) { + // span directive without closing part before end or line-break, so not valid + return null; + } else { + return d; + } +} + + +function getDirectiveLength (d, text, i) { + if (!d) { + return 0; + } + const begin = i; + i++; + if (isQuoteDirective(d)) { + i += text.slice(begin).split(/\n[^>]/).shift().length; + return i-begin; + } else { + // Set i to the last char just before the end of the direcive + const dtype = styling_map[d].type; // directive type + const isEnd = (i) => text.slice(i,i+d.length) === d || (dtype === 'span' && text[i] === '\n'); + while (!isEnd(i)) { i++; } + if (i <= text.length-d.length) { + i += d.length; + return i-begin; + } + } + return 0; +} + + +function getDirectiveAndLength (text, i) { + const d = getDirective(text, i); + const length = getDirectiveLength(d, text, i); + return { d, length }; +} + + +function getDirectiveMarkup (text) { + let i = 0, html = ''; + while (i < text.length) { + const d = getDirective(text, i); + if (d) { + const begin = i; + const template = styling_templates[styling_map[d].name]; + i += d.length; + + if (isQuoteDirective(d)) { + // The only directive that doesn't have a closing tag + i += text.slice(i).split(/\n[^>]/).shift().length; + const newtext = text.slice(begin+1, i).replace(/\n>/g, '\n'); + html += `${template(getDirectiveMarkup(newtext))}` + } else { + const dtype = styling_map[d].type; // directive type + // Set i to the last char just before the end of the direcive + const isEnd = (i) => (i === text.length || text.slice(i,i+d.length) === d || dtype === 'span' && text[i] === '\n'); + while (!isEnd(i)) { i++; } + + if (i <= text.length-d.length) { + if (recursive_directives.includes(d)) { + html += `${d}${template(getDirectiveMarkup(text.slice(begin+1, i)))}${d}` + } else { + html += `${d}${template(text.slice(begin+d.length, i))}${d}` + } + i += d.length; + } else { + // We reached the end without finding a match, go back to i+1 + i = begin+1; + } + } + } else { + html += text[i]; + i++; + } + } + return html; +} + + +export function getMessageStylingReferences (text) { + let i = 0; + const references = []; + while (i < text.length) { + const { d, length } = getDirectiveAndLength(text, i); + if (d) { + const end = i+length; + references.push({'begin': i, end, 'html': getDirectiveMarkup(escape(text.slice(i, end))) }); + i = end; + } + i++; + } + return references; +} diff --git a/src/templates/directives/body.js b/src/templates/directives/body.js index d64dd33a33..e1cdf28f2e 100644 --- a/src/templates/directives/body.js +++ b/src/templates/directives/body.js @@ -1,8 +1,10 @@ import URI from "urijs"; import log from '@converse/headless/log'; +import tpl_message_styling from '../message_styling.js'; import { _converse, api, converse } from "@converse/headless/converse-core"; import { convertASCII2Emoji, getEmojiMarkup, getCodePointReferences, getShortnameReferences } from "@converse/headless/converse-emoji.js"; import { directive, html } from "lit-html"; +import { getMessageStylingReferences } from "@converse/headless/utils/parse-helpers"; import { until } from 'lit-html/directives/until.js'; const u = converse.env.utils; @@ -88,6 +90,10 @@ class MessageText extends String { } } +function addStylingReferences(text) { + const refs = getMessageStylingReferences(text); + refs.forEach((ref) => text.addTemplateResult(ref.begin, ref.end, tpl_message_styling({'html':ref.html}))); +} function addMapURLs (text) { const regex = /geo:([\-0-9.]+),([\-0-9.]+)(?:,([\-0-9.]+))?(?:\?(.*))?/g; @@ -202,7 +208,7 @@ class MessageBodyRenderer { addMapURLs(text); await addEmojis(text); addReferences(text, this.model); - + addStylingReferences(text); /** * Synchronous event which provides a hook for transforming a chat message's body text * after the default transformations have been applied. diff --git a/src/templates/message_styling.js b/src/templates/message_styling.js new file mode 100644 index 0000000000..e804e45f17 --- /dev/null +++ b/src/templates/message_styling.js @@ -0,0 +1,15 @@ +import xss from "xss/dist/xss"; +import { html } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html.js"; + +export default (o) => { + const whiteList = { + b: [], + blockquote: [], + code: ['class'], + del: [], + em: [], + i: [], + }; + return html`${unsafeHTML(xss.filterXSS(o.html, { whiteList }))}`; +}