Styling: Use zero-width space and maintain position of newline char

Fixes #2879
This commit is contained in:
JC Brand 2022-08-15 14:58:11 +02:00
parent 7f99b24e28
commit 97be0bd8ac
4 changed files with 76 additions and 65 deletions

View File

@ -12,6 +12,7 @@
- Update `nick` attribute on ChatRoom when user nickname changes - Update `nick` attribute on ChatRoom when user nickname changes
- Restrict editing of MUC messages to ones with the same XEP-0421 occupant ID - Restrict editing of MUC messages to ones with the same XEP-0421 occupant ID
- #2870: Fix for multiple URLs to be linkified when sent together in chat and adds a test for this. - #2870: Fix for multiple URLs to be linkified when sent together in chat and adds a test for this.
- #2879: Quotes, lines not aligned to the first line
- #2925: Fix missing disco-items in browser storage. - #2925: Fix missing disco-items in browser storage.
- #2936: Fix documentation about enable_smacks option, which is true by default. - #2936: Fix documentation about enable_smacks option, which is true by default.
- #3005: Fix MUC messages with a fallback body not rendering. - #3005: Fix MUC messages with a fallback body not rendering.

View File

@ -202,7 +202,7 @@ describe("An incoming chat Message", function () {
await _converse.handleMessageStanza(msg); await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
'Here\'s a code block: \n'+ 'Here\'s a code block: \n'+
'<div class="styling-directive">```</div><code class="block">Inside the code-block, &lt;code&gt;hello&lt;/code&gt; we don\'t enable *styling hints* like ~these~\n'+ '<div class="styling-directive">```</div><code class="block">Inside the code-block, &lt;code&gt;hello&lt;/code&gt; we don\'t enable *styling hints* like ~these~\n'+
'</code><div class="styling-directive">```</div>' '</code><div class="styling-directive">```</div>'
@ -247,67 +247,70 @@ describe("An incoming chat Message", function () {
await _converse.handleMessageStanza(msg); await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1);
msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
'<blockquote><a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a> \n <a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a></blockquote>'); '<blockquote>'+
'<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n\u200B\u200B'+
'<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>'+
'</blockquote>');
msg_text = `> This is quoted text\n>This 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) msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg); await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
'<blockquote>This is quoted text\nThis is also quoted</blockquote>\nThis is not quoted'); '<blockquote>This is quoted text\n\u200BThis is also quoted</blockquote>\nThis is not quoted');
msg_text = `> This is *quoted* text\n>This 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) msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg); await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
'<blockquote>This is <span class="styling-directive">*</span><b>quoted</b><span class="styling-directive">*</span> text\n'+ '<blockquote>This is <span class="styling-directive">*</span><b>quoted</b><span class="styling-directive">*</span> text\n\u200B'+
'This is <span class="styling-directive">`</span><code>also _quoted_</code><span class="styling-directive">`</span></blockquote>\n'+ 'This is <span class="styling-directive">`</span><code>also _quoted_</code><span class="styling-directive">`</span></blockquote>\n'+
'This is not quoted'); 'This is not quoted');
msg_text = `> > This is doubly quoted text`; msg_text = `> > This is doubly quoted text`;
msg = mock.createChatMessage(_converse, contact_jid, msg_text) msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg); await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === "<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>"); expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe("<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>");
msg_text = `>> This is doubly quoted text`; msg_text = `>> This is doubly quoted text`;
msg = mock.createChatMessage(_converse, contact_jid, msg_text) msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg); await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === "<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>"); expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe("<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_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) msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg); await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 6);
msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
'<blockquote>'+ '<blockquote>'+
'<div class="styling-directive">```</div>'+ '<div class="styling-directive">```</div>'+
'<code class="block">ignored\n &lt;span&gt;&lt;/span&gt; (println "Hello, world!")\n'+ '<code class="block">\u200Bignored\n\u200B\u200B&lt;span&gt;&lt;/span&gt; (println "Hello, world!")\n\u200B'+
'</code><div class="styling-directive">```</div>\n'+ '</code><div class="styling-directive">```</div>\n\u200B\u200B'+
' This should show up as monospace, preformatted text ^'+ 'This should show up as monospace, preformatted text ^'+
'</blockquote>'); '</blockquote>');
msg_text = '> ```\n> (println "Hello, world!")\n\nThe entire blockquote is a preformatted text block, but this line is plaintext!'; 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) msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg); await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 6); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 7);
msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
'<blockquote>```\n (println "Hello, world!")</blockquote>\n\n'+ '<blockquote>```\n\u200B\u200B(println "Hello, world!")</blockquote>\n\n'+
'The entire blockquote is a preformatted text block, but this line is plaintext!'); 'The entire blockquote is a preformatted text block, but this line is plaintext!');
msg_text = '> Also, icons.js is loaded from /dist, instead of dist.\nhttps://conversejs.org/docs/html/configuration.html#assets-path' msg_text = '> Also, icons.js is loaded from /dist, instead of dist.\nhttps://conversejs.org/docs/html/configuration.html#assets-path'
msg = mock.createChatMessage(_converse, contact_jid, msg_text) msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg); await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 7); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 8);
msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
'<blockquote>Also, icons.js is loaded from /dist, instead of dist.</blockquote>\n'+ '<blockquote>Also, icons.js is loaded from /dist, instead of dist.</blockquote>\n'+
@ -316,7 +319,7 @@ describe("An incoming chat Message", function () {
msg_text = '> Where is it located?\ngeo:37.786971,-122.399677'; msg_text = '> Where is it located?\ngeo:37.786971,-122.399677';
msg = mock.createChatMessage(_converse, contact_jid, msg_text) msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg); await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 8); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 9);
msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
'<blockquote>Where is it located?</blockquote>\n'+ '<blockquote>Where is it located?</blockquote>\n'+
@ -326,7 +329,7 @@ describe("An incoming chat Message", function () {
msg_text = '> What do you think of it?\n :poop:'; msg_text = '> What do you think of it?\n :poop:';
msg = mock.createChatMessage(_converse, contact_jid, msg_text) msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg); await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 9); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 10);
msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
'<blockquote>What do you think of it?</blockquote>\n <span title=":poop:">💩</span>'); '<blockquote>What do you think of it?</blockquote>\n <span title=":poop:">💩</span>');
@ -334,7 +337,7 @@ describe("An incoming chat Message", function () {
msg_text = '> What do you think of it?\n~hello~'; msg_text = '> What do you think of it?\n~hello~';
msg = mock.createChatMessage(_converse, contact_jid, msg_text) msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg); await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 10); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 11);
msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
'<blockquote>What do you think of it?</blockquote>\n<span class="styling-directive">~</span><del>hello</del><span class="styling-directive">~</span>'); '<blockquote>What do you think of it?</blockquote>\n<span class="styling-directive">~</span><del>hello</del><span class="styling-directive">~</span>');
@ -342,7 +345,7 @@ describe("An incoming chat Message", function () {
msg_text = 'hello world > this is not a quote'; msg_text = 'hello world > this is not a quote';
msg = mock.createChatMessage(_converse, contact_jid, msg_text) msg = mock.createChatMessage(_converse, contact_jid, msg_text)
await _converse.handleMessageStanza(msg); await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 11); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 12);
msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === 'hello world &gt; this is not a quote'); await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === 'hello world &gt; this is not a quote');
@ -369,7 +372,7 @@ describe("An incoming chat Message", function () {
}).nodeTree; }).nodeTree;
await _converse.handleMessageStanza(msg); await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 12); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 13);
msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
`<blockquote>What do you think of it <span class="mention" data-uri="romeo@montague.lit">romeo</span>?</blockquote>\n `+ `<blockquote>What do you think of it <span class="mention" data-uri="romeo@montague.lit">romeo</span>?</blockquote>\n `+

View File

@ -209,14 +209,16 @@ export class RichText extends String {
} }
/** /**
* Look for XEP-0393 styling directives and add templates for rendering * Look for XEP-0393 styling directives and add templates for rendering them.
* them.
*/ */
addStyling () { addStyling () {
if (!containsDirectives(this, this.mentions)) {
return;
}
const references = []; const references = [];
if (containsDirectives(this, this.mentions)) {
const mention_ranges = this.mentions.map(m => const mention_ranges = this.mentions.map(m =>
Array.from({ 'length': Number(m.end) }, (v, i) => Number(m.begin) + i) Array.from({ 'length': Number(m.end) }, (_, i) => Number(m.begin) + i)
); );
let i = 0; let i = 0;
while (i < this.length) { while (i < this.length) {
@ -247,7 +249,6 @@ export class RichText extends String {
} }
i++; i++;
} }
}
references.forEach(ref => this.addTemplateResult(ref.begin, ref.end, ref.template)); references.forEach(ref => this.addTemplateResult(ref.begin, ref.end, ref.template));
} }

View File

@ -70,15 +70,18 @@ function isValidDirective (d, text, i, opening) {
} }
/** /**
* Given a specific index "i" of "text", return the directive it matches or * Given a specific index "i" of "text", return the directive it matches or null otherwise.
* null otherwise.
* @param { String } text - The text in which the directive appears * @param { String } text - The text in which the directive appears
* @param { Number } i - The directive index * @param { Number } i - The directive index
* @param { Boolean } opening - Whether we're looking for an opening or closing directive * @param { Boolean } opening - Whether we're looking for an opening or closing directive
*/ */
function getDirective (text, i, opening=true) { function getDirective (text, i, opening=true) {
let d; let d;
if ((/(^```\s*\n|^```\s*$)/).test(text.slice(i)) && (i === 0 || text[i-1] === '\n' || text[i-1] === '>')) {
if (
(/(^```[\s,\u200B]*\n)|(^```[\s,\u200B]*$)/).test(text.slice(i)) &&
(i === 0 || text[i-1] === '>' || (/\n\u200B{0,2}$/).test(text.slice(0, i)))
) {
d = text.slice(i, i+3); d = text.slice(i, i+3);
} else if (styling_directives.includes(text.slice(i, i+1))) { } else if (styling_directives.includes(text.slice(i, i+1))) {
d = text.slice(i, i+1); d = text.slice(i, i+1);
@ -98,7 +101,8 @@ function getDirective (text, i, opening=true) {
* @param { String } text -The text in which the directive appears * @param { String } text -The text in which the directive appears
*/ */
function getDirectiveLength (d, text, i) { function getDirectiveLength (d, text, i) {
if (!d) { return 0; } if (!d) return 0;
const begin = i; const begin = i;
i += d.length; i += d.length;
if (isQuoteDirective(d)) { if (isQuoteDirective(d)) {
@ -145,7 +149,9 @@ export function getDirectiveTemplate (d, text, offset, options) {
const template = styling_templates[styling_map[d].name]; const template = styling_templates[styling_map[d].name];
if (isQuoteDirective(d)) { if (isQuoteDirective(d)) {
const newtext = text const newtext = text
.replace(/\n>/g, ' \n') // Don't show the directive itself // Don't show the directive itself
.replace(/\n>\s/g, '\n\u200B\u200B')
.replace(/\n>/g, '\n\u200B')
.replace(/\n$/, ''); // Trim line-break at the end .replace(/\n$/, ''); // Trim line-break at the end
return template(newtext, offset, options); return template(newtext, offset, options);
} else { } else {