From f9650f33be513eab3082567285adfa7d8de6b739 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 16 Oct 2020 09:02:12 +0200 Subject: [PATCH] Add support for XEP-0393 message styling Fixes #1083 Directives are rendered as templates and their bodies are MessageText instances. We thereby achieve the necessary nesting of directives (and other rich elements inside directives) by letting each directive body render itself similarly to how the whole message body is rendered. --- CHANGES.md | 8 +- README.md | 1 + docs/source/configuration.rst | 11 +- index.html | 1 + karma.conf.js | 1 + sass/_messages.scss | 15 ++ spec/me-messages.js | 2 +- spec/mentions.js | 4 +- spec/messages.js | 1 - spec/styling.js | 335 ++++++++++++++++++++++++++++ spec/xss.js | 8 +- src/headless/converse-chat.js | 1 + src/headless/converse-core.js | 1 + src/headless/utils/parse-helpers.js | 2 +- src/headless/utils/stanza.js | 8 +- src/shared/message/styling.js | 157 +++++++++++++ src/shared/message/text.js | 267 ++++++++++++++++++++++ src/templates/directives/body.js | 206 +---------------- src/templates/directives/styling.js | 16 ++ 19 files changed, 833 insertions(+), 212 deletions(-) create mode 100644 spec/styling.js create mode 100644 src/shared/message/styling.js create mode 100644 src/shared/message/text.js create mode 100644 src/templates/directives/styling.js diff --git a/CHANGES.md b/CHANGES.md index b4aec3e0a..039131b41 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,10 @@ # Changelog +## 8.0.0 (Unreleased) + +- #1083: Add support for XEP-0393 Message Styling +- New configuration setting: [allow_message_styling](https://conversejs.org/docs/html/configuration.html#allow-message-styling) instead. + ## 7.0.2 (2020-11-23) - Updated translations: de, nb, gl, tr @@ -19,6 +24,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 +35,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 d4a9dc8a4..36e29afb3 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/docs/source/configuration.rst b/docs/source/configuration.rst index 277c1f950..756cfec7b 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -184,6 +184,13 @@ Determines who is allowed to retract messages. If set to ``'all'``, then normal users may retract their own messages and ``'moderators'`` may retract the messages of other users. +allow_message_styling +--------------------- + +* Default: ``true`` +* Possible values: ``true``, ``false`` + +Determines wehether support for XEP-0393 Message Styling hints are enabled or not. allow_muc --------- @@ -1603,10 +1610,10 @@ From version 7.0.0 onwards, Converse supports storing data in When Converse is running inside a web browser extension, it can now take advantage of storage optimized to meet the specific storage needs of extensions. -BrowserExtSync represents the sync storage area. +BrowserExtSync represents the sync storage area. Items in sync storage are synced by the browser and are available across all instances of that browser that the user is logged into, across different devices. -BrowserExtLocal represents the local storage area. +BrowserExtLocal represents the local storage area. Items in local storage are local to the machine the extension was installed on diff --git a/index.html b/index.html index f4a73102a..53e71404f 100644 --- a/index.html +++ b/index.html @@ -197,6 +197,7 @@
  • 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 e8dd9c9ca..03c27cc75 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/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 7607f813d..9003b1734 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 937b5b53c..f9fe7fde9 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 6948b1bdf..a88a8e9a1 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/messages.js b/spec/messages.js index 59b3d9f81..a781c1e3a 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/styling.js b/spec/styling.js new file mode 100644 index 000000000..382148f88 --- /dev/null +++ b/spec/styling.js @@ -0,0 +1,335 @@ +/*global mock, converse */ + +const { u, Promise, $msg } = converse.env; + +describe("An incoming chat Message", function () { + + it("can have styling disabled via an \"unstyled\" element", + 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 have styling disabled via the allow_message_styling setting", + mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {'allow_message_styling': false}, + 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).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(false); + + 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 _'); + + // Directives which are parts of words aren't matched + msg_text = `Go to ~https://conversejs.org~now _please_`; + 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, '') === + 'Go to ~https://conversejs.org~now _please_'); + + msg_text = `Go to _https://converse_js.org_ _please_`; + 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, '') === + 'Go to _'+ + 'https://converse_js.org'+ + '_ _please_'); + + 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/xss.js b/spec/xss.js index a72e465a9..dfdaf3190 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-chat.js b/src/headless/converse-chat.js index b64d92c30..d029dca29 100644 --- a/src/headless/converse-chat.js +++ b/src/headless/converse-chat.js @@ -43,6 +43,7 @@ converse.plugins.add('converse-chat', { api.settings.extend({ 'allow_message_corrections': 'all', 'allow_message_retraction': 'all', + 'allow_message_styling': true, 'auto_join_private_chats': [], 'clear_messages_on_reconnection': false, 'filter_by_resource': false, diff --git a/src/headless/converse-core.js b/src/headless/converse-core.js index c276f0a7a..3dac49035 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 8036f3e53..a0765fa28 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 fa642126a..374e6c107 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 000000000..853b6d97e --- /dev/null +++ b/src/shared/message/styling.js @@ -0,0 +1,157 @@ +/** + * @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. + * It's valid if it's not part of a word. + * @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))) { + 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))) { + 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) { + let d; + if ((/(^```\s*\n|^```\s*$)/).test(text.slice(i)) && (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'); +} + + +/** + * Given a directive "d", which occurs in "text" at index "i", check that it + * has a valid closing directive and return the length from start to end of the + * directive. + * @param { String } d -The directive + * @param { Number } i - The directive index + * @param { String } text -The text in which the directive appears + */ +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 if (styling_map[d].type === 'span') { + const line = text.slice(i+1).split('\n').shift(); + let j = 0; + let idx = line.indexOf(d); + while (idx !== -1) { + if (isDirectiveEnd(d, i+1+idx, text)) return idx+1+2*d.length; + idx = line.indexOf(d, j++); + } + return 0; + } else { + const substring = text.slice(i+1); + let j; + let idx = substring.indexOf(d); + while (idx !== -1) { + if (isDirectiveEnd(d, i+1+idx, text)) return idx+1+2*d.length; + idx = substring.indexOf(d, j++); + } + return 0; + } +} + + +export function getDirectiveAndLength (text, i) { + const d = getDirective(text, i); + const length = d ? getDirectiveLength(d, text, i) : 0; + return length > 0 ? { 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') || !api.settings.get('allow_message_styling')) { + 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 8a117ec84..4b18762e3 100644 --- a/src/templates/directives/body.js +++ b/src/templates/directives/body.js @@ -1,170 +1,12 @@ -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})); - } - }); -} - - class MessageBodyRenderer { constructor (component) { @@ -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 000000000..ec5c73f2b --- /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)));