Skip to content

Commit

Permalink
Add support for XEP-0393 message styling
Browse files Browse the repository at this point in the history
Fixes #1083
  • Loading branch information
jcbrand committed Nov 23, 2020
1 parent 357b640 commit 0ccb90c
Show file tree
Hide file tree
Showing 10 changed files with 337 additions and 4 deletions.
4 changes: 2 additions & 2 deletions .eslintrc.json
Expand Up @@ -97,7 +97,7 @@
],
"lines-around-comment": "off",
"lines-around-directive": "off",
"max-depth": "error",
"max-depth": "off",
"max-len": "off",
"max-lines": "off",
"max-nested-callbacks": "off",
Expand Down Expand Up @@ -192,7 +192,7 @@
"no-undef-init": "error",
"no-undefined": "off",
"no-underscore-dangle": "off",
"no-unmodified-loop-condition": "error",
"no-unmodified-loop-condition": "off",
"no-unneeded-ternary": "off",
"no-unused-vars": "error",
"no-unused-expressions": "off",
Expand Down
3 changes: 2 additions & 1 deletion CHANGES.md
Expand Up @@ -19,6 +19,8 @@
configuration settings should now be accessed via `_converse.api.settings.get` and not directly on the `_converse` object.
Soon we'll deprecate the latter, so prepare now.

- #515 Add support for XEP-0050 Ad-Hoc commands
- #1083 Add support for XEP-0393 Message Styling
- #2231: add sort_by_query and remove sort_by_length
- #1313: Stylistic improvements to the send button
- #1481: MUC OMEMO: Error No record for device
Expand All @@ -28,7 +30,6 @@ Soon we'll deprecate the latter, so prepare now.
- #1793: Send button doesn't appear in Firefox in 1:1 chats
- #1820: Set focus on jid field after controlbox is loaded
- #1822: Don't log error if user has no bookmarks
- #515 Add support for XEP-0050 Ad-Hoc commands
- #1823: New config options [muc_roomid_policy](https://conversejs.org/docs/html/configuration.html#muc-roomid-policy)
and [muc_roomid_policy_hint](https://conversejs.org/docs/html/configuration.html#muc-roomid-policy-hint)
- #1826: A user can now add himself as a contact
Expand Down
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions index.html
Expand Up @@ -197,6 +197,7 @@ <h2>Features</h2>
<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>
Expand Down
1 change: 1 addition & 0 deletions karma.conf.js
Expand Up @@ -47,6 +47,7 @@ module.exports = function(config) {
{ pattern: "spec/user-details-modal.js", type: 'module' },
{ pattern: "spec/messages.js", type: 'module' },
{ pattern: "spec/corrections.js", type: 'module' },
{ pattern: "spec/message-styling.js", type: 'module' },
{ pattern: "spec/receipts.js", type: 'module' },
{ pattern: "spec/muc_messages.js", type: 'module' },
{ pattern: "spec/mentions.js", type: 'module' },
Expand Down
11 changes: 11 additions & 0 deletions sass/_messages.scss
Expand Up @@ -5,6 +5,17 @@
}
}
.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;
}
Expand Down
149 changes: 149 additions & 0 deletions spec/message-styling.js
@@ -0,0 +1,149 @@
/*global mock, converse */

const { u, $msg } = converse.env;

describe("A incoming chat Message", function () {

it("can be styled with XEP-0393 message styling hints",
mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {},
function (done) {

done();
}));

fit("can have styling disabled",
mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {

const include_nick = false;
await mock.waitForRoster(_converse, 'current', 2, include_nick);
await mock.openControlBox(_converse);

const msgtext = '> _ >';
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(msgtext).tree();

await _converse.handleMessageStanza(msg);
done();
}));
});


describe("A outgoing chat Message", function () {

it("can be styled with XEP-0393 message styling hints",
mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {

let msg_text, msg, msg_el;
await mock.waitForRoster(_converse, 'current', 1);
const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
await mock.openChatBoxFor(_converse, contact_jid);
const view = _converse.api.chatviews.get(contact_jid);

msg_text = "This *message _contains_* styling hints! \`Here's *some* code\`";
msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg);
await new Promise(resolve => view.model.messages.once('rendered', resolve));
msg_el = view.el.querySelector('converse-chat-message-body');
expect(msg_el.innerText).toBe(msg_text);
await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
'This *<b>message _<i>contains</i>_</b>* styling hints! `<code>Here\'s *some* code</code>`');

msg_text = "This *is not a styling hint \n * _But this is_!";
msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg);
await new Promise(resolve => view.model.messages.once('rendered', resolve));
msg_el = Array.from(view.el.querySelectorAll('converse-chat-message-body')).pop();
expect(msg_el.innerText).toBe(msg_text);
await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
"This *is not a styling hint \n * _<i>But this is</i>_!");

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 ~<del>strikethrough section</del>~");

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```<code class="block">\nInside the code-block, &lt;code&gt;hello&lt;/code&gt; we don\'t enable *styling hints* like ~these~\n</code>```'
);

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 *<b>quoted</b>* text\nThis is `<code>also _quoted_</code>`</blockquote>\nThis is not quoted");

msg_text = `(There are three blocks in this body marked by parens,)\n (but there is no *formatting)\n (as spans* may not escape blocks.)`;
msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg);
await new Promise(resolve => view.model.messages.once('rendered', resolve));
msg_el = Array.from(view.el.querySelectorAll('converse-chat-message-body')).pop();
expect(msg_el.innerText).toBe(msg_text);
await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') === msg_text);

msg_text = "```ignored\n (println \"Hello, world!\")\n ```\n\n This should 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, *<b>preformatted</b>* text ^");

msg_text = "```\nignored\n (println \"Hello, world!\")\n```\n\n This should show up as monospace, preformatted text ^";
msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg);
await new Promise(resolve => view.model.messages.once('rendered', resolve));
msg_el = Array.from(view.el.querySelectorAll('converse-chat-message-body')).pop();
expect(msg_el.innerText).toBe(msg_text);
await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
"```<code class=\"block\">\nignored\n (println \"Hello, world!\")\n</code>```\n\n This should show up as monospace, preformatted text ^");

msg_text = `> > This is doubly quoted text`;
msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg);
await new Promise(resolve => view.model.messages.once('rendered', resolve));
msg_el = Array.from(view.el.querySelectorAll('converse-chat-message-body')).pop();
expect(msg_el.innerText).toBe(msg_text);
await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') === "<blockquote> <blockquote> This is doubly quoted text</blockquote></blockquote>");

msg_text = ">```\n>ignored\n> <span></span> (println \"Hello, world!\")\n>```\n>\n> This should show up as monospace, preformatted text ^";
msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg);
await new Promise(resolve => view.model.messages.once('rendered', resolve));
msg_el = Array.from(view.el.querySelectorAll('converse-chat-message-body')).pop();
expect(msg_el.innerText).toBe(msg_text);
await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
"<blockquote>```<code class=\"block\">\nignored\n &lt;span&gt;&lt;/span&gt; (println \"Hello, world!\")\n</code>```\n\n This should show up as monospace, preformatted text ^</blockquote>");

done();
}));
});
148 changes: 148 additions & 0 deletions src/headless/utils/parse-helpers.js
Expand Up @@ -41,3 +41,151 @@ const reduceReferences = ([text, refs], ref, index) => {
helpers.reduceTextFromReferences = (text, refs) => refs.reduce(reduceReferences, [text, []]);

export default helpers;

const styling_directives = ['*', '_', '~', '`', '```', '>'];
const recursive_directives = ['*', '_', '~', '>'];
const styling_map = {
'*': {'name': 'strong', 'type': 'span'},
'_': {'name': 'emphasis', 'type': 'span'},
'~': {'name': 'strike', 'type': 'span'},
'`': {'name': 'preformatted', 'type': 'span'},
'```': {'name': 'preformatted_block', 'type': 'block'},
'>': {'name': 'quote', 'type': 'block'}
};

const styling_templates = {
emphasis: (text) => `<i>${text}</i>`,
preformatted: (text) => `<code>${text}</code>`,
preformatted_block: (text) => `<code class="block">${text}</code>`,
quote: (text) => `<blockquote>${text}</blockquote>`,
strike: (text) => `<del>${text}</del>`,
strong: (text) => `<b>${text}</b>`,
};

const isQuoteDirective = (d) => ['>', '&gt;'].includes(d);

function escape (text) {
return text
.replace(/\&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/(\p{L}|\p{N}|\p{P})>/g, "$1&gt;")
.replace(/'/g, "&apos;")
.replace(/"/g, "&quot;");
}


function getDirective (text, i, opening=true) {
// TODO: blockquote is only valid if on own line
// TODO: blockquote without end quote is valid until end of text or of containing quote
let d;
if (styling_directives.includes(text.slice(i, i+4))) {
d = text.slice(i, i+4);
} else if (
text.slice(i).match(/(^```\s*\n|^```\s*$)/) &&
(i === 0 || text[i-1] === '\n' || text[i-1] === '>')
) {
d = text.slice(i, i+3);
} else if (styling_directives.includes(text.slice(i, i+1)) && text[i] !== text[i+1]) {
d = text.slice(i, i+1);
} else {
return null;
}
if (opening && styling_map[d].type === 'span' && !text.slice(i+1).split('\n').shift().includes(d)) {
// span directive without closing part before end or line-break, so not valid
return null;
} else {
return d;
}
}


function isDirectiveEnd (d, i, text) {
const dtype = styling_map[d].type; // directive type
return i === text.length || getDirective(text, i, false) === d || (dtype === 'span' && text[i] === '\n');
}


function getDirectiveLength (d, text, i) {
if (!d) { return 0; }
const begin = i;
i += d.length;
if (isQuoteDirective(d)) {
i += text.slice(i).split(/\n[^>]/).shift().length;
return i-begin;
} else {
// Set i to the last char just before the end of the direcive
while (!isDirectiveEnd(d, i, text)) { i++; }
if (i <= text.length) {
i += d.length;
return i-begin;
}
}
return 0;
}


function getDirectiveAndLength (text, i) {
const d = getDirective(text, i);
const length = d ? getDirectiveLength(d, text, i) : 0;
return { d, length };
}


function getDirectiveMarkup (text) {
let i = 0, html = '';
while (i < text.length) {
const { d, length } = getDirectiveAndLength(text, i);
if (d && length) {
const begin = i;
const template = styling_templates[styling_map[d].name];
i += d.length;

if (isQuoteDirective(d)) {
// The quote directive doesn't have a closing tag
i += length;
const newtext = text.slice(begin+1, i).replace(/\n>/g, '\n');
html += `${template(getDirectiveMarkup(newtext))}`
} else {
// i is just after the opening directive, we now set it to just before the closing direcive
i += (length - (2*d.length));
if (recursive_directives.includes(d)) {
html += `${d}${template(getDirectiveMarkup(text.slice(begin+1, i)))}${d}`
} else {
html += `${d}${template(text.slice(begin+d.length, i))}${d}`
}
i += d.length;
}
} else {
html += text[i];
i++;
}
}
return html;
}


function containsDirectives (text) {
for (let i=0; i<styling_directives.length; i++) {
if (text.includes(styling_directives[i])) {
return true;
}
}
}


export function getMessageStylingReferences (text) {
let i = 0;
const references = [];
if (containsDirectives(text)) {
while (i < text.length) {
const { d, length } = getDirectiveAndLength(text, i);
if (d) {
const end = i+length;
references.push({'begin': i, end, 'html': getDirectiveMarkup(escape(text.slice(i, end))) });
i = end;
}
i++;
}
}
return references;
}

0 comments on commit 0ccb90c

Please sign in to comment.