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.
This commit is contained in:
parent
7ae2b48da9
commit
f9650f33be
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
---------
|
||||
|
@ -197,6 +197,7 @@
|
||||
<li>Hidden messages (aka Spoilers) (<a href="https://xmpp.org/extensions/xep-0382.html" target="_blank" rel="noopener">XEP 382</a>)</li>
|
||||
<li>Client state indication (<a href="https://xmpp.org/extensions/xep-0352.html" target="_blank" rel="noopener">XEP 352</a>)</li>
|
||||
<li>OMEMO encrypted messaging (<a href="https://xmpp.org/extensions/xep-0384.html" target="_blank" rel="noopener">XEP 384</a>)</li>
|
||||
<li>Message Styling (<a href="https://xmpp.org/extensions/xep-0384.html" target="_blank" rel="noopener">XEP 393</a>)</li>
|
||||
<li>Anonymous logins, see the <a href="/demo/anonymous.html" target="_blank" rel="noopener">anonymous login demo</a></li>
|
||||
<li>Message corrections, retractions and moderation</li>
|
||||
<li>Translated into over 30 languages</li>
|
||||
|
@ -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' },
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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 <span class="mention">z3r0</span> '+
|
||||
'<span class="mention mention--self badge badge-info">tom</span> '+
|
||||
'<span class="mention">mr.robot</span>, how are you?');
|
||||
'<blockquote>hello <span class="mention">z3r0</span> <span class="mention mention--self badge badge-info">tom</span> <span class="mention">mr.robot</span>, how are you?</blockquote>');
|
||||
done();
|
||||
}));
|
||||
});
|
||||
|
@ -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: <a target="_blank" rel="noopener" href="https://www.opkode.com/?id=0">https://www.opkode.com/?id=0</a>');
|
||||
|
||||
|
||||
// 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';
|
||||
|
335
spec/styling.js
Normal file
335
spec/styling.js
Normal file
@ -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 <span class="styling-directive">*</span>'+
|
||||
'<b>message <span class="styling-directive">_</span><i>contains</i><span class="styling-directive">_</span></b>'+
|
||||
'<span class="styling-directive">*</span>'+
|
||||
' styling hints! '+
|
||||
'<span class="styling-directive">`</span><code>Here\'s *some* code</code><span class="styling-directive">`</span>'
|
||||
);
|
||||
|
||||
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 <span class="styling-directive">~</span><del>strikethrough section</del><span class="styling-directive">~</span>');
|
||||
|
||||
// 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, '') ===
|
||||
'<span class="styling-directive">~</span>'+
|
||||
'<del>Check out this site: <a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a></del>'+
|
||||
'<span class="styling-directive">~</span>');
|
||||
|
||||
// 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, '') ===
|
||||
'<span class="styling-directive">*</span>'+
|
||||
'<b><a target="_blank" rel="noopener" href="https://conversejs.org/logo/conversejs-filled.svg">https://conversejs.org/logo/conversejs-filled.svg</a></b>'+
|
||||
'<span class="styling-directive">*</span>');
|
||||
|
||||
// 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, '') ===
|
||||
'<span class="styling-directive">~</span><del> Hello! <span title=":poop:">💩</span> </del><span class="styling-directive">~</span>');
|
||||
|
||||
// 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'+
|
||||
' * <span class="styling-directive">_</span><i>But this is</i><span class="styling-directive">_</span>!');
|
||||
|
||||
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'+
|
||||
' <span class="styling-directive">~</span><del>strikethrough</del><span class="styling-directive">~</span>');
|
||||
|
||||
// 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, '') ===
|
||||
'_<span class="styling-directive">_</span><i> hello world </i><span class="styling-directive">_</span>');
|
||||
|
||||
// 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 <span class="styling-directive">_</span><i>please</i><span class="styling-directive">_</span>');
|
||||
|
||||
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 <span class="styling-directive">_</span>'+
|
||||
'<i><a target="_blank" rel="noopener" href="https://converse_js.org/">https://converse_js.org</a></i>'+
|
||||
'<span class="styling-directive">_</span> <span class="styling-directive">_</span><i>please</i><span class="styling-directive">_</span>');
|
||||
|
||||
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, <code>hello</code> 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'+
|
||||
'<div class="styling-directive">```</div><code class="block">Inside the code-block, <code>hello</code> we don\'t enable *styling hints* like ~these~\n'+
|
||||
'</code><div class="styling-directive">```</div>'
|
||||
);
|
||||
|
||||
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, '') ===
|
||||
'<div class="styling-directive">```</div>'+
|
||||
'<code class="block">ignored\n(println "Hello, world!")\n</code>'+
|
||||
'<div class="styling-directive">```</div>\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, '+
|
||||
'<span class="styling-directive">*</span><b>preformatted</b><span class="styling-directive">*</span> 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, '') ===
|
||||
'<blockquote> This is quoted text\nThis is also quoted</blockquote>\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, '') ===
|
||||
'<blockquote> This is <span class="styling-directive">*</span><b>quoted</b><span class="styling-directive">*</span> text\n'+
|
||||
'This is <span class="styling-directive">`</span><code>also _quoted_</code><span class="styling-directive">`</span></blockquote>\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, '') === "<blockquote> <blockquote> This is doubly quoted text</blockquote></blockquote>");
|
||||
|
||||
msg_text = ">```\n>ignored\n> <span></span> (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, '') ===
|
||||
'<blockquote>'+
|
||||
'<div class="styling-directive">```</div>'+
|
||||
'<code class="block">ignored\n <span></span> (println "Hello, world!")\n'+
|
||||
'</code><div class="styling-directive">```</div>\n'+
|
||||
' This should show up as monospace, preformatted text ^'+
|
||||
'</blockquote>');
|
||||
|
||||
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, '') ===
|
||||
'<blockquote> ```\n (println "Hello, world!")</blockquote>\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 <span class="styling-directive">*</span><b>message mentions <span class="mention mention--self badge badge-info">romeo</span></b><span class="styling-directive">*</span>');
|
||||
done();
|
||||
}));
|
||||
});
|
@ -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, '') ===
|
||||
'<a target="_blank" rel="noopener" href="http://www.opkode.com/%27onmouseover=%27alert%281%29%27whatever">http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever</a>');
|
||||
|
||||
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, '') === '<a target="_blank" rel="noopener" href="https://en.wikipedia.org/wiki/Ender%27s_Game">'+message+'</a>');
|
||||
|
||||
message = "<https://bugs.documentfoundation.org/show_bug.cgi?id=123737>";
|
||||
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 = '<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, '') ===
|
||||
@ -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, '') ===
|
||||
|
@ -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,
|
||||
|
@ -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');
|
||||
|
@ -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 = {};
|
||||
|
@ -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 <body> 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 <body> 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),
|
||||
|
157
src/shared/message/styling.js
Normal file
157
src/shared/message/styling.js
Normal file
@ -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`<span class="styling-directive">_</span><i>${renderStylingDirectiveBody(txt, m, i)}</i><span class="styling-directive">_</span>`,
|
||||
'preformatted': txt => html`<span class="styling-directive">\`</span><code>${txt}</code><span class="styling-directive">\`</span>`,
|
||||
'preformatted_block': txt => html`<div class="styling-directive">\`\`\`</div><code class="block">${txt}</code><div class="styling-directive">\`\`\`</div>`,
|
||||
'quote': (txt, m, i) => html`<blockquote>${renderStylingDirectiveBody(txt, m, i)}</blockquote>`,
|
||||
'strike': (txt, m, i) => html`<span class="styling-directive">~</span><del>${renderStylingDirectiveBody(txt, m, i)}</del><span class="styling-directive">~</span>`,
|
||||
'strong': (txt, m, i) => html`<span class="styling-directive">*</span><b>${renderStylingDirectiveBody(txt, m, i)}</b><span class="styling-directive">*</span>`,
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 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<styling_directives.length; i++) {
|
||||
if (text.includes(styling_directives[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
267
src/shared/message/text.js
Normal file
267
src/shared/message/text.js
Normal file
@ -0,0 +1,267 @@
|
||||
import URI from 'urijs';
|
||||
import log from '@converse/headless/log';
|
||||
import { _converse, api, converse } from '@converse/headless/converse-core';
|
||||
import { convertASCII2Emoji } from '@converse/headless/converse-emoji.js';
|
||||
import { html } from 'lit-html';
|
||||
import { containsDirectives, getDirectiveAndLength, getDirectiveTemplate, isQuoteDirective } from './styling.js';
|
||||
import { getCodePointReferences, getEmojiMarkup, getShortnameReferences } from '../../headless/converse-emoji.js';
|
||||
|
||||
const u = converse.env.utils;
|
||||
|
||||
const isString = (s) => typeof s === 'string';
|
||||
|
||||
const tpl_mention_with_nick = (o) => html`<span class="mention mention--self badge badge-info">${o.mention}</span>`;
|
||||
const tpl_mention = (o) => html`<span class="mention">${o.mention}</span>`;
|
||||
|
||||
|
||||
/**
|
||||
* @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 `<message>` 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 <img> 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 `<img>` 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], []);
|
||||
}
|
||||
}
|
@ -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 `<message>` 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`<span class="mention mention--self badge badge-info">${o.mention}</span>`;
|
||||
const tpl_mention = (o) => html`<span class="mention">${o.mention}</span>`;
|
||||
|
||||
|
||||
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 () {
|
||||
|
16
src/templates/directives/styling.js
Normal file
16
src/templates/directives/styling.js
Normal file
@ -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)));
|
Loading…
Reference in New Issue
Block a user