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 b4aec3e0a6..4d9bd0f975 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -19,6 +19,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 @@ -28,7 +30,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 e8dd9c9ca2..e9477f7f0e 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/me-messages.js", type: 'module' }, diff --git a/sass/_messages.scss b/sass/_messages.scss index 7607f813d2..9003b17347 100644 --- a/sass/_messages.scss +++ b/sass/_messages.scss @@ -1,10 +1,25 @@ #conversejs { + .styling-directive { + color: var(--subdued-color); + } + .older-msg { time { font-weight: bold; } } .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; + } .mention { font-weight: bold; } diff --git a/spec/me-messages.js b/spec/me-messages.js index 937b5b53c1..f9fe7fde95 100644 --- a/spec/me-messages.js +++ b/spec/me-messages.js @@ -4,7 +4,7 @@ const { u, sizzle, $msg } = converse.env; describe("A Groupchat Message", function () { - fit("supports the /me command", + it("supports the /me command", mock.initConverse( ['rosterGroupsFetched'], {}, async function (done, _converse) { diff --git a/spec/mentions.js b/spec/mentions.js index 6948b1bdf8..a88a8e9a18 100644 --- a/spec/mentions.js +++ b/spec/mentions.js @@ -107,9 +107,7 @@ describe("An incoming groupchat message", function () { const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text')); expect(message.classList.length).toEqual(1); expect(message.innerHTML.replace(//g, '')).toBe( - '>hello z3r0 '+ - 'tom '+ - 'mr.robot, how are you?'); + '
    hello z3r0 tom mr.robot, how are you?
    '); done(); })); }); diff --git a/spec/message-styling.js b/spec/message-styling.js new file mode 100644 index 0000000000..19680331fc --- /dev/null +++ b/spec/message-styling.js @@ -0,0 +1,283 @@ +/*global mock, converse */ + +const { u, Promise, $msg } = converse.env; + +fdescribe("A incoming chat Message", function () { + + it("can have styling disabled", + mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + const include_nick = false; + await mock.waitForRoster(_converse, 'current', 2, include_nick); + await mock.openControlBox(_converse); + + const msg_text = '> _ >'; + const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msg = $msg({ + 'from': sender_jid, + 'id': u.getUniqueId(), + 'to': _converse.connection.jid, + 'type': 'chat', + 'xmlns': 'jabber:client' + }).c('body').t(msg_text).up() + .c('unstyled', {'xmlns': 'urn:xmpp:styling:0'}).tree(); + await _converse.handleMessageStanza(msg); + + const view = _converse.api.chatviews.get(sender_jid); + await u.waitUntil(() => view.model.messages.length); + expect(view.model.messages.models[0].get('is_unstyled')).toBe(true); + + setTimeout(() => { + const msg_el = view.el.querySelector('converse-chat-message-body'); + expect(msg_el.innerText).toBe(msg_text); + done(); + }, 500); + })); + + it("can be styled with span 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 = "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~'); + + // Span directives containing hyperlinks + msg_text = "~Check out this site: https://conversejs.org~" + 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, '') === + '~'+ + 'Check out this site: https://conversejs.org'+ + '~'); + + // Images inside directives aren't shown inline + const base_url = 'https://conversejs.org'; + msg_text = `*${base_url}/logo/conversejs-filled.svg*`; + 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, '') === + '*'+ + 'https://conversejs.org/logo/conversejs-filled.svg'+ + '*'); + + // Emojis inside directives + msg_text = `~ Hello! :poop: ~`; + 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, '') === + '~ Hello! 💩 ~'); + + // Span directives don't cross lines + 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 = `(There are three blocks in this body marked by parens,)\n (but there is no *formatting)\n (as spans* may not escape blocks.)\n ~strikethrough~`; + 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, '') === + '(There are three blocks in this body marked by parens,)\n'+ + ' (but there is no *formatting)\n'+ + ' (as spans* may not escape blocks.)\n'+ + ' ~strikethrough~'); + + // Some edge-case (unspecified) spans + msg_text = `__ hello world _`; + 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, '') === + '__ hello world _'); + + done(); + })); + + it("can be styled with block 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 = `Here's a code block: \n\`\`\`\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: \n'+ + '
    ```
    Inside the code-block, <code>hello</code> we don\'t enable *styling hints* like ~these~\n'+ + '
    ```
    ' + ); + + msg_text = "```\nignored\n(println \"Hello, world!\")\n```\nThis 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'+ + 'This should show up as monospace, preformatted text ^'); + + + msg_text = "```ignored\n (println \"Hello, world!\")\n ```\n\n This should not 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 not show up as monospace, '+ + '*preformatted* text ^'); + done(); + })); + + it("can be styled with quote 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 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
    \nThis 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\n'+ + 'This is `also _quoted_`
    \n'+ + 'This is not quoted'); + + 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
    "); + + msg_text = ">```\n>ignored\n> (println \"Hello, world!\")\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'+ + ' This should show up as monospace, preformatted text ^'+ + '
    '); + + msg_text = '> ```\n> (println "Hello, world!")\n\nThe entire blockquote is a preformatted text block, but this line is plaintext!'; + 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, '') === + '
    ```\n (println "Hello, world!")
    \n\n'+ + 'The entire blockquote is a preformatted text block, but this line is plaintext!'); + done(); + })); +}); + + +describe("A outgoing groupchat Message", function () { + + it("can be styled with span XEP-0393 message styling hints that contain mentions", + mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.api.chatviews.get(muc_jid); + const msg_text = "This *message mentions romeo*"; + const msg = $msg({ + from: 'lounge@montague.lit/gibson', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(msg_text).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'23', 'end':'29', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).nodeTree; + await view.model.handleMessageStanza(msg); + const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text')); + expect(message.classList.length).toEqual(1); + + const 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 *message mentions romeo*'); + done(); + })); +}); diff --git a/spec/messages.js b/spec/messages.js index 59b3d9f811..a781c1e3a9 100644 --- a/spec/messages.js +++ b/spec/messages.js @@ -569,7 +569,6 @@ describe("A Chat Message", function () { await u.waitUntil(() => msg.innerHTML.replace(//g, '') === 'This message contains a hyperlink with forbidden query params: https://www.opkode.com/?id=0'); - // Test assigning a string to filter_url_query_params _converse.api.settings.set('filter_url_query_params', 'utm_medium'); message = 'Another message with a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&utm_medium=2&s=1'; diff --git a/spec/xss.js b/spec/xss.js index a72e465a96..dfdaf31907 100644 --- a/spec/xss.js +++ b/spec/xss.js @@ -1,4 +1,4 @@ -/*global mock */ +/*global mock, converse */ const $pres = converse.env.$pres; const sizzle = converse.env.sizzle; @@ -145,13 +145,11 @@ describe("XSS", function () { expect(msg.textContent).toEqual(message); expect(msg.innerHTML.replace(//g, '')) .toEqual('http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever'); - await u.waitUntil(() => msg.innerHTML.replace(//g, '') === 'http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever'); message = 'http://www.opkode.com/"onmouseover="alert(1)"whatever'; await mock.sendMessage(view, message); - msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop(); expect(msg.textContent).toEqual(message); await u.waitUntil(() => msg.innerHTML.replace(//g, '') === @@ -159,14 +157,12 @@ describe("XSS", function () { message = "https://en.wikipedia.org/wiki/Ender's_Game"; await mock.sendMessage(view, message); - msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop(); expect(msg.textContent).toEqual(message); await u.waitUntil(() => msg.innerHTML.replace(//g, '') === ''+message+''); message = ""; await mock.sendMessage(view, message); - msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop(); expect(msg.textContent).toEqual(message); await u.waitUntil(() => msg.innerHTML.replace(//g, '') === @@ -174,7 +170,6 @@ describe("XSS", function () { message = ''; await mock.sendMessage(view, message); - msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop(); expect(msg.textContent).toEqual(message); await u.waitUntil(() => msg.innerHTML.replace(//g, '') === @@ -182,7 +177,6 @@ describe("XSS", function () { message = `https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2` await mock.sendMessage(view, message); - msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop(); expect(msg.textContent).toEqual(message); await u.waitUntil(() => msg.innerHTML.replace(//g, '') === diff --git a/src/headless/converse-core.js b/src/headless/converse-core.js index c276f0a7ac..3dac490358 100644 --- a/src/headless/converse-core.js +++ b/src/headless/converse-core.js @@ -52,6 +52,7 @@ Strophe.addNamespace('RSM', 'http://jabber.org/protocol/rsm'); Strophe.addNamespace('SID', 'urn:xmpp:sid:0'); Strophe.addNamespace('SPOILER', 'urn:xmpp:spoiler:0'); Strophe.addNamespace('STANZAS', 'urn:ietf:params:xml:ns:xmpp-stanzas'); +Strophe.addNamespace('STYLING', 'urn:xmpp:styling:0'); Strophe.addNamespace('VCARD', 'vcard-temp'); Strophe.addNamespace('VCARDUPDATE', 'vcard-temp:x:update'); Strophe.addNamespace('XFORM', 'jabber:x:data'); diff --git a/src/headless/utils/parse-helpers.js b/src/headless/utils/parse-helpers.js index 8036f3e537..a0765fa28e 100644 --- a/src/headless/utils/parse-helpers.js +++ b/src/headless/utils/parse-helpers.js @@ -1,7 +1,7 @@ /** * @copyright 2020, the Converse.js contributors * @license Mozilla Public License (MPLv2) - * @description Pure functions to help funcitonally parse messages. + * @description Pure functions to help functionally parse messages. * @todo Other parsing helpers can be made more abstract and placed here. */ const helpers = {}; diff --git a/src/headless/utils/stanza.js b/src/headless/utils/stanza.js index fa642126a6..374e6c107f 100644 --- a/src/headless/utils/stanza.js +++ b/src/headless/utils/stanza.js @@ -441,9 +441,10 @@ const st = { * @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker? * @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker? * @property { Boolean } is_only_emojis - Does the message body contain only emojis? - * @property { Boolean } is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message) * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message? * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone? + * @property { Boolean } is_unstyled - Whether XEP-0393 styling hints should be ignored + * @property { Boolean } is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message) * @property { Object } encrypted - XEP-0384 encryption payload attributes * @property { String } body - The contents of the tag of the message stanza * @property { String } chat_state - The XEP-0085 chat state notification contained in this message @@ -489,6 +490,7 @@ const st = { 'is_delayed': !!delay, 'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length, 'is_marker': !!marker, + 'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length, 'marker_id': marker && marker.getAttribute('id'), 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), 'nick': contact?.attributes?.nickname, @@ -581,9 +583,10 @@ const st = { * @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker? * @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker? * @property { Boolean } is_only_emojis - Does the message body contain only emojis? - * @property { Boolean } is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message) * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message? * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone? + * @property { Boolean } is_unstyled - Whether XEP-0393 styling hints should be ignored + * @property { Boolean } is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message) * @property { Object } encrypted - XEP-0384 encryption payload attributes * @property { String } body - The contents of the tag of the message stanza * @property { String } chat_state - The XEP-0085 chat state notification contained in this message @@ -632,6 +635,7 @@ const st = { 'is_headline': st.isHeadline(stanza), 'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length, 'is_marker': !!marker, + 'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length, 'marker_id': marker && marker.getAttribute('id'), 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), 'receipt_id': getReceiptId(stanza), diff --git a/src/shared/message/styling.js b/src/shared/message/styling.js new file mode 100644 index 0000000000..8a84b9b19d --- /dev/null +++ b/src/shared/message/styling.js @@ -0,0 +1,146 @@ +/** + * @copyright 2020, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + * @description Utility functions to help with parsing XEP-393 message styling hints + * @todo Other parsing helpers can be made more abstract and placed here. + */ +import { html } from 'lit-element'; +import { renderStylingDirectiveBody } from '../../templates/directives/styling.js'; + + +const styling_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'} +}; + +const dont_escape = ['_', '>', '`', '~']; + +const styling_templates = { + // m is the chatbox model + // i is the offset of this directive relative to the start of the original message + 'emphasis': (txt, m, i) => html`_${renderStylingDirectiveBody(txt, m, i)}_`, + 'preformatted': txt => html`\`${txt}\``, + 'preformatted_block': txt => html`
    \`\`\`
    ${txt}
    \`\`\`
    `, + 'quote': (txt, m, i) => html`
    ${renderStylingDirectiveBody(txt, m, i)}
    `, + 'strike': (txt, m, i) => html`~${renderStylingDirectiveBody(txt, m, i)}~`, + 'strong': (txt, m, i) => html`*${renderStylingDirectiveBody(txt, m, i)}*`, +}; + + +/** + * Checks whether a given character "d" at index "i" of "text" is a valid + * opening or closing directive. + * @param { String } d - The potential directive + * @param { String } text - The text in which the directive appears + * @param { Number } i - The directive index + * @param { Boolean } opening - Check for a valid opening or closing directive + */ +function isValidDirective (d, text, i, opening) { + // Ignore directives that are parts of words + // More info on the Regexes used here: https://javascript.info/regexp-unicode#unicode-properties-p + if (opening) { + const regex = RegExp(dont_escape.includes(d) ? `(\\p{L}|\\p{N})${d}` : `(\\p{L}|\\p{N})\\${d}`, 'u'); + if (i > 1 && regex.test(text.slice(i-1, i+d.length))) { + return false; + } + } else { + const regex = RegExp(dont_escape.includes(d) ? `${d}(\\p{L}|\\p{N})` : `\\${d}(\\p{L}|\\p{N})`, 'u'); + if (i < text.length-1 && regex.test(text.slice(i-d.length, i+1))) { + return false; + } + } + if (opening && 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 false; + } + return true; +} + + +/** + * Given a specific index "i" of "text", return the directive it matches or + * null otherwise. + * @param { String } text - The text in which the directive appears + * @param { Number } i - The directive index + * @param { Boolean } opening - Whether we're looking for an opening or closing directive + */ +function getDirective (text, i, opening=true) { + // 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 (text.slice(i).match(/(^```\s*\n|^```\s*$)/) && (i === 0 || text[i-1] === '\n' || text[i-1] === '>')) { + d = text.slice(i, i+3); + } else if (styling_directives.includes(text.slice(i, i+1)) && text[i] !== text[i+1]) { + d = text.slice(i, i+1); + if (!isValidDirective(d, text, i, opening)) return null; + } else { + return null; + } + return d; +} + + +/** + * Given an opening directive "d", an index "i" and the text, check whether + * we've found the closing directive. + * @param { String } d -The directive + * @param { Number } i - The directive index + * @param { String } text -The text in which the directive appears + */ +function isDirectiveEnd (d, i, text) { + const dtype = styling_map[d].type; // directive type + return i === text.length || getDirective(text, i, false) === d || (dtype === 'span' && text[i] === '\n'); +} + + +function getDirectiveLength (d, text, i) { + if (!d) { return 0; } + const begin = i; + i += d.length; + if (isQuoteDirective(d)) { + i += text.slice(i).split(/\n[^>]/).shift().length; + return i-begin; + } else { + // Set i to the last char just before the end of the direcive + while (!isDirectiveEnd(d, i, text)) { i++; } + if (i <= text.length) { + i += d.length; + return i-begin; + } + } + return 0; +} + + +export function getDirectiveAndLength (text, i) { + const d = getDirective(text, i); + const length = d ? getDirectiveLength(d, text, i) : 0; + return { d, length }; +} + + +export const isQuoteDirective = (d) => ['>', '>'].includes(d); + + +export function getDirectiveTemplate (d, text, model, offset) { + const template = styling_templates[styling_map[d].name]; + if (isQuoteDirective(d)) { + return template(text.replace(/\n>/g, '\n'), model, offset); + } else { + return template(text, model, offset); + } +} + + +export function containsDirectives (text) { + for (let i=0; i typeof s === 'string'; + +const tpl_mention_with_nick = (o) => html`${o.mention}`; +const tpl_mention = (o) => html`${o.mention}`; + + +/** + * @class MessageText + * A String subclass that is used to represent the rich text + * of a chat message. + * + * The "rich" parts of the text is represented by lit-html TemplateResult + * objects which are added via the {@link MessageText.addTemplateResult} + * method and saved as metadata. + * + * By default Converse adds TemplateResults to support emojis, hyperlinks, + * images, map URIs and mentions. + * + * 3rd party plugins can listen for the `beforeMessageBodyTransformed` + * and/or `afterMessageBodyTransformed` events and then call + * `addTemplateResult` on the MessageText instance in order to add their own + * rich features. + */ +export class MessageText extends String { + + /** + * Create a new {@link MessageText} instance. + * @param { String } text - The plain text that was received from the `` stanza. + * @param { Message } model + * @param { Integer } offset - The offset of this particular piece of text + * from the start of the original message text. This is necessary because + * MessageText instances can be nested when templates call directives + * which create new MessageText instances (as happens with XEP-393 styling directives). + * @param { Boolean } show_images - Whether image URLs should be rendered as tags. + * @param { Function } onImgLoad + * @param { Function } onImgClick + */ + constructor (text, model, offset=0, show_images, onImgLoad, onImgClick) { + super(text); + this.model = model; + this.offset = offset; + this.onImgClick = onImgClick; + this.onImgLoad = onImgLoad; + this.references = []; + this.show_images = show_images; + this.payload = []; + } + + addHyperlinks (text) { + const objs = []; + try { + const parse_options = { 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi }; + URI.withinString(text, (url, start, end) => { + objs.push({url, start, end}) + return url; + } , parse_options); + } catch (error) { + log.debug(error); + return; + } + objs.forEach(url_obj => { + const url_text = text.slice(url_obj.start, url_obj.end); + const filtered_url = u.filterQueryParamsFromURL(url_text); + this.addTemplateResult( + url_obj.start, + url_obj.end, + this.show_images && u.isImageURL(url_text) && u.isImageDomainAllowed(url_text) ? + u.convertToImageTag(filtered_url, this.onImgLoad, this.onImgClick) : + u.convertUrlToHyperlink(filtered_url), + ); + }); + } + + addMapURLs (text) { + const regex = /geo:([\-0-9.]+),([\-0-9.]+)(?:,([\-0-9.]+))?(?:\?(.*))?/g; + const matches = text.matchAll(regex); + for (const m of matches) { + this.addTemplateResult( + m.index, + m.index+m.input.length, + u.convertUrlToHyperlink(m.input.replace(regex, _converse.geouri_replacement)) + ); + } + } + + async addEmojis (text) { + await api.emojis.initialize(); + const references = [...getShortnameReferences(text.toString()), ...getCodePointReferences(text.toString())]; + references.forEach(e => { + this.addTemplateResult( + e.begin, + e.end, + getEmojiMarkup(e, {'add_title_wrapper': true}) + ); + }); + } + + addMentionReferences (text, offset) { + if (!this.model.collection) { + // This model doesn't belong to a collection anymore, so it must be + // have been removed in the meantime and can be ignored. + log.debug('addMentionReferences: ignoring dangling model'); + return; + } + const nick = this.model.collection.chatbox.get('nick'); + this.model.get('references')?.forEach(ref => { + const begin = Number(ref.begin)-offset; + if (begin >= text.length) { + return; + } + const end = Number(ref.end)-offset; + const mention = text.slice(begin, end); + if (mention === nick) { + this.addTemplateResult(begin, end, tpl_mention_with_nick({mention})); + } else { + this.addTemplateResult(begin, end, tpl_mention({mention})); + } + }); + } + + addStylingReferences () { + if (this.model.get('is_unstyled')) { + return; + } + let i = 0; + const references = []; + if (containsDirectives(this)) { + while (i < this.length) { + const { d, length } = getDirectiveAndLength(this, i); + if (d && length) { + const begin = d === '```' ? i+d.length+1 : i+d.length; + const end = i+length; + const slice_end = isQuoteDirective(d) ? end : end-d.length; + references.push({ + 'begin': i, + 'template': getDirectiveTemplate(d, this.slice(begin, slice_end), this.model, i+d.length), + end, + }); + i = end; + } + i++; + } + } + references.forEach(ref => this.addTemplateResult(ref.begin, ref.end, ref.template)); + } + + trimMeMessage () { + if (this.offset === 0) { + // Subtract `/me ` from 3rd person messages + if (this.isMeCommand()) { + this.payload[0] = this.payload[0].substring(4); + } + } + } + + /** + * Parse the text and add template references for rendering the "rich" parts. + * + * @param { MessageText } text + * @param { Boolean } show_images - Should URLs of images be rendered as `` tags? + * @param { Function } onImgLoad + * @param { Function } onImgClick + **/ + async addTemplates() { + /** + * Synchronous event which provides a hook for transforming a chat message's body text + * before the default transformations have been applied. + * @event _converse#beforeMessageBodyTransformed + * @param { _converse.Message } model - The model representing the message + * @param { MessageText } text - A {@link MessageText } instance. You + * can call {@link MessageText#addTemplateResult } on it in order to + * add TemplateResult objects meant to render rich parts of the + * message. + * @example _converse.api.listen.on('beforeMessageBodyTransformed', (view, text) => { ... }); + */ + await api.trigger('beforeMessageBodyTransformed', this, {'Synchronous': true}); + + this.addStylingReferences(); + const payload = this.marshall(); + + let offset = this.offset; + for (const text of payload) { + if (isString(text)) { + this.addHyperlinks(text); + this.addMapURLs(text); + await this.addEmojis(text); + this.addMentionReferences(text, offset); + offset += text.length; + } else { + offset += text.begin; + } + } + + /** + * Synchronous event which provides a hook for transforming a chat message's body text + * after the default transformations have been applied. + * @event _converse#afterMessageBodyTransformed + * @param { _converse.Message } model - The model representing the message + * @param { MessageText } text - A {@link MessageText } instance. You + * can call {@link MessageText#addTemplateResult} on it in order to + * add TemplateResult objects meant to render rich parts of the + * message. + * @example _converse.api.listen.on('afterMessageBodyTransformed', (view, text) => { ... }); + */ + await api.trigger('afterMessageBodyTransformed', this, {'Synchronous': true}); + + this.payload = this.marshall(); + this.trimMeMessage(); + this.payload = this.payload.map(item => isString(item) ? item : item.template); + } + + /** + * The "rich" markup parts of a chat message are represented by lit-html + * TemplateResult objects. + * + * This method can be used to add new template results to this message's + * text. + * + * @method MessageText.addTemplateResult + * @param { Number } begin - The starting index of the plain message text + * which is being replaced with markup. + * @param { Number } end - The ending index of the plain message text + * which is being replaced with markup. + * @param { Object } template - The lit-html TemplateResult instance + */ + addTemplateResult (begin, end, template) { + this.references.push({begin, end, template}); + } + + isMeCommand () { + const text = this.toString(); + if (!text) { + return false; + } + return text.startsWith('/me '); + } + + static replaceText (text) { + return convertASCII2Emoji(text.replace(/\n\n+/g, '\n\n')); + } + + marshall () { + let list = [this.toString()]; + this.references + .sort((a, b) => b.begin - a.begin) + .forEach(ref => { + const text = list.shift(); + list = [ + text.slice(0, ref.begin), + ref, + text.slice(ref.end), + ...list + ]; + }); + return list.reduce((acc, i) => isString(i) ? [...acc, MessageText.replaceText(i)] : [...acc, i], []); + } +} diff --git a/src/templates/directives/body.js b/src/templates/directives/body.js index 8a117ec843..4b18762e37 100644 --- a/src/templates/directives/body.js +++ b/src/templates/directives/body.js @@ -1,168 +1,10 @@ -import URI from "urijs"; -import log from '@converse/headless/log'; -import { _converse, api, converse } from "@converse/headless/converse-core"; -import { convertASCII2Emoji, getEmojiMarkup, getCodePointReferences, getShortnameReferences } from "@converse/headless/converse-emoji.js"; +import { MessageText } from '../../shared/message/text.js'; +import { api, converse } from "@converse/headless/converse-core"; import { directive, html } from "lit-html"; import { until } from 'lit-html/directives/until.js'; -const u = converse.env.utils; - - -/** - * @class MessageText - * A String subclass that is used to represent the rich text - * of a chat message. - * - * The "rich" parts of the text is represented by lit-html TemplateResult - * objects which are added via the {@link MessageText.addTemplateResult} - * method and saved as metadata. - * - * By default Converse adds TemplateResults to support emojis, hyperlinks, - * images, map URIs and mentions. - * - * 3rd party plugins can listen for the `beforeMessageBodyTransformed` - * and/or `afterMessageBodyTransformed` events and then call - * `addTemplateResult` on the MessageText instance in order to add their own - * rich features. - */ -class MessageText extends String { - - /** - * Create a new {@link MessageText} instance. - * @param { String } text - The plain text that was received from the `` stanza. - */ - constructor (text) { - super(text); - this.references = []; - } - - /** - * The "rich" markup parts of a chat message are represented by lit-html - * TemplateResult objects. - * - * This method can be used to add new template results to this message's - * text. - * - * @method MessageText.addTemplateResult - * @param { Number } begin - The starting index of the plain message text - * which is being replaced with markup. - * @param { Number } end - The ending index of the plain message text - * which is being replaced with markup. - * @param { Object } template - The lit-html TemplateResult instance - */ - addTemplateResult (begin, end, template) { - this.references.push({begin, end, template}); - } - - isMeCommand () { - const text = this.toString(); - if (!text) { - return false; - } - return text.startsWith('/me '); - } - - static replaceText (text) { - return convertASCII2Emoji(text.replace(/\n\n+/g, '\n\n')); - } - - marshall () { - let list = [this.toString()]; - this.references - .sort((a, b) => b.begin - a.begin) - .forEach(ref => { - const text = list.shift(); - list = [ - text.slice(0, ref.begin), - ref.template, - text.slice(ref.end), - ...list - ]; - }); - - // Subtract `/me ` from 3rd person messages - if (this.isMeCommand()) list[0] = list[0].substring(4); - - const isString = (s) => typeof s === 'string'; - return list.reduce((acc, i) => isString(i) ? [...acc, MessageText.replaceText(i)] : [...acc, i], []); - } -} - - -function addMapURLs (text) { - const regex = /geo:([\-0-9.]+),([\-0-9.]+)(?:,([\-0-9.]+))?(?:\?(.*))?/g; - const matches = text.matchAll(regex); - for (const m of matches) { - text.addTemplateResult( - m.index, - m.index+m.input.length, - u.convertUrlToHyperlink(m.input.replace(regex, _converse.geouri_replacement)) - ); - } -} - - -function addHyperlinks (text, onImgLoad, onImgClick) { - const objs = []; - try { - const parse_options = { 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi }; - URI.withinString(text, (url, start, end) => { - objs.push({url, start, end}) - return url; - } , parse_options); - } catch (error) { - log.debug(error); - return; - } - const show_images = api.settings.get('show_images_inline'); - objs.forEach(url_obj => { - const url_text = text.slice(url_obj.start, url_obj.end); - const filtered_url = u.filterQueryParamsFromURL(url_text); - text.addTemplateResult( - url_obj.start, - url_obj.end, - show_images && u.isImageURL(url_text) && u.isImageDomainAllowed(url_text) ? - u.convertToImageTag(filtered_url, onImgLoad, onImgClick) : - u.convertUrlToHyperlink(filtered_url), - ); - }); -} - - -async function addEmojis (text) { - await api.emojis.initialize(); - const references = [...getShortnameReferences(text.toString()), ...getCodePointReferences(text.toString())]; - references.forEach(e => { - text.addTemplateResult( - e.begin, - e.end, - getEmojiMarkup(e, {'add_title_wrapper': true}) - ); - }); -} - - -const tpl_mention_with_nick = (o) => html`${o.mention}`; -const tpl_mention = (o) => html`${o.mention}`; - -function addReferences (text, model) { - if (!model.collection) { - // This model doesn't belong to a collection anymore, so it must be - // have been removed in the meantime and can be ignored. - log.debug('addReferences: ignoring dangling model'); - return; - } - const nick = model.collection.chatbox.get('nick'); - model.get('references')?.forEach(ref => { - const mention = text.slice(ref.begin, ref.end); - if (mention === nick) { - text.addTemplateResult(ref.begin, ref.end, tpl_mention_with_nick({mention})); - } else { - text.addTemplateResult(ref.begin, ref.end, tpl_mention({mention})); - } - }); -} +const u = converse.env.utils; class MessageBodyRenderer { @@ -186,42 +28,18 @@ class MessageBodyRenderer { } async transform () { - const text = new MessageText(this.text); - /** - * Synchronous event which provides a hook for transforming a chat message's body text - * before the default transformations have been applied. - * @event _converse#beforeMessageBodyTransformed - * @param { _converse.Message } model - The model representing the message - * @param { MessageText } text - A {@link MessageText } instance. You - * can call {@link MessageText#addTemplateResult } on it in order to - * add TemplateResult objects meant to render rich parts of the - * message. - * @example _converse.api.listen.on('beforeMessageBodyTransformed', (view, text) => { ... }); - */ - await api.trigger('beforeMessageBodyTransformed', this.model, text, {'Synchronous': true}); - - addHyperlinks( - text, + const show_images = api.settings.get('show_images_inline'); + const offset = 0; + const text = new MessageText( + this.text, + this.model, + offset, + show_images, () => this.scrollDownOnImageLoad(), ev => this.component.showImageModal(ev) ); - addMapURLs(text); - await addEmojis(text); - addReferences(text, this.model); - - /** - * Synchronous event which provides a hook for transforming a chat message's body text - * after the default transformations have been applied. - * @event _converse#afterMessageBodyTransformed - * @param { _converse.Message } model - The model representing the message - * @param { MessageText } text - A {@link MessageText } instance. You - * can call {@link MessageText#addTemplateResult} on it in order to - * add TemplateResult objects meant to render rich parts of the - * message. - * @example _converse.api.listen.on('afterMessageBodyTransformed', (view, text) => { ... }); - */ - await api.trigger('afterMessageBodyTransformed', this.model, text, {'Synchronous': true}); - return text.marshall(); + await text.addTemplates(); + return text.payload; } render () { diff --git a/src/templates/directives/styling.js b/src/templates/directives/styling.js new file mode 100644 index 0000000000..ec5c73f2b5 --- /dev/null +++ b/src/templates/directives/styling.js @@ -0,0 +1,16 @@ +import { MessageText } from '../../shared/message/text.js'; +import { directive, html } from "lit-html"; +import { until } from 'lit-html/directives/until.js'; + + +async function transform (t) { + await t.addTemplates(); + return t.payload; +} + +function renderer (text, model, offset) { + const t = new MessageText(text, model, offset, false); + return html`${until(transform(t), html`${t}`)}`; +} + +export const renderStylingDirectiveBody = directive((text, model, offset) => p => p.setValue(renderer(text, model, offset)));