Commit f9650f33 authored by JC Brand's avatar JC Brand

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.
parent 7ae2b48d
# Changelog # 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) ## 7.0.2 (2020-11-23)
- Updated translations: de, nb, gl, tr - Updated translations: de, nb, gl, tr
...@@ -19,6 +24,8 @@ ...@@ -19,6 +24,8 @@
configuration settings should now be accessed via `_converse.api.settings.get` and not directly on the `_converse` object. 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. 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 - #2231: add sort_by_query and remove sort_by_length
- #1313: Stylistic improvements to the send button - #1313: Stylistic improvements to the send button
- #1481: MUC OMEMO: Error No record for device - #1481: MUC OMEMO: Error No record for device
...@@ -28,7 +35,6 @@ Soon we'll deprecate the latter, so prepare now. ...@@ -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 - #1793: Send button doesn't appear in Firefox in 1:1 chats
- #1820: Set focus on jid field after controlbox is loaded - #1820: Set focus on jid field after controlbox is loaded
- #1822: Don't log error if user has no bookmarks - #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) - #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) 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 - #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. ...@@ -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-0372](https://xmpp.org/extensions/xep-0372.html) References
- [XEP-0382](https://xmpp.org/extensions/xep-0382.html) Spoiler messages - [XEP-0382](https://xmpp.org/extensions/xep-0382.html) Spoiler messages
- [XEP-0384](https://xmpp.org/extensions/xep-0384.html) OMEMO Encryption - [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-0422](https://xmpp.org/extensions/xep-0422.html) Message Fastening (limited support)
- [XEP-0424](https://xmpp.org/extensions/xep-0424.html) Message Retractions - [XEP-0424](https://xmpp.org/extensions/xep-0424.html) Message Retractions
- [XEP-0425](https://xmpp.org/extensions/xep-0425.html) Message Moderation - [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 ...@@ -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 users may retract their own messages and ``'moderators'`` may retract the messages of
other users. 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 allow_muc
--------- ---------
...@@ -1603,10 +1610,10 @@ From version 7.0.0 onwards, Converse supports storing data in ...@@ -1603,10 +1610,10 @@ From version 7.0.0 onwards, Converse supports storing data in
When Converse is running inside a web browser extension, it can now take advantage of storage optimized to meet the specific storage needs of extensions. When Converse is running inside a web browser extension, it can now take advantage of storage optimized to meet the specific storage needs of extensions.
BrowserExtSync represents the sync storage area. BrowserExtSync represents the sync storage area.
Items in sync storage are synced by the browser and are available across all instances of that browser that the user is logged into, across different devices. Items in sync storage are synced by the browser and are available across all instances of that browser that the user is logged into, across different devices.
BrowserExtLocal represents the local storage area. BrowserExtLocal represents the local storage area.
Items in local storage are local to the machine the extension was installed on Items in local storage are local to the machine the extension was installed on
......
...@@ -197,6 +197,7 @@ ...@@ -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>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>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>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>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>Message corrections, retractions and moderation</li>
<li>Translated into over 30 languages</li> <li>Translated into over 30 languages</li>
......
...@@ -47,6 +47,7 @@ module.exports = function(config) { ...@@ -47,6 +47,7 @@ module.exports = function(config) {
{ pattern: "spec/user-details-modal.js", type: 'module' }, { pattern: "spec/user-details-modal.js", type: 'module' },
{ pattern: "spec/messages.js", type: 'module' }, { pattern: "spec/messages.js", type: 'module' },
{ pattern: "spec/corrections.js", type: 'module' }, { pattern: "spec/corrections.js", type: 'module' },
{ pattern: "spec/styling.js", type: 'module' },
{ pattern: "spec/receipts.js", type: 'module' }, { pattern: "spec/receipts.js", type: 'module' },
{ pattern: "spec/muc_messages.js", type: 'module' }, { pattern: "spec/muc_messages.js", type: 'module' },
{ pattern: "spec/me-messages.js", type: 'module' }, { pattern: "spec/me-messages.js", type: 'module' },
......
#conversejs { #conversejs {
.styling-directive {
color: var(--subdued-color);
}
.older-msg { .older-msg {
time { time {
font-weight: bold; font-weight: bold;
} }
} }
.message { .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 { .mention {
font-weight: bold; font-weight: bold;
} }
......
...@@ -4,7 +4,7 @@ const { u, sizzle, $msg } = converse.env; ...@@ -4,7 +4,7 @@ const { u, sizzle, $msg } = converse.env;
describe("A Groupchat Message", function () { describe("A Groupchat Message", function () {
fit("supports the /me command", it("supports the /me command",
mock.initConverse( mock.initConverse(
['rosterGroupsFetched'], {}, ['rosterGroupsFetched'], {},
async function (done, _converse) { async function (done, _converse) {
......
...@@ -107,9 +107,7 @@ describe("An incoming groupchat message", function () { ...@@ -107,9 +107,7 @@ describe("An incoming groupchat message", function () {
const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text')); const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
expect(message.classList.length).toEqual(1); expect(message.classList.length).toEqual(1);
expect(message.innerHTML.replace(/<!---->/g, '')).toBe( expect(message.innerHTML.replace(/<!---->/g, '')).toBe(
'&gt;hello <span class="mention">z3r0</span> '+ '<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>');
'<span class="mention mention--self badge badge-info">tom</span> '+
'<span class="mention">mr.robot</span>, how are you?');
done(); done();
})); }));
}); });
......
...@@ -569,7 +569,6 @@ describe("A Chat Message", function () { ...@@ -569,7 +569,6 @@ describe("A Chat Message", function () {
await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') === 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>'); '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 // Test assigning a string to filter_url_query_params
_converse.api.settings.set('filter_url_query_params', 'utm_medium'); _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'; message = 'Another message with a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&utm_medium=2&s=1';
......
This diff is collapsed.
/*global mock */ /*global mock, converse */
const $pres = converse.env.$pres; const $pres = converse.env.$pres;
const sizzle = converse.env.sizzle; const sizzle = converse.env.sizzle;
...@@ -145,13 +145,11 @@ describe("XSS", function () { ...@@ -145,13 +145,11 @@ describe("XSS", function () {
expect(msg.textContent).toEqual(message); expect(msg.textContent).toEqual(message);
expect(msg.innerHTML.replace(/<!---->/g, '')) expect(msg.innerHTML.replace(/<!---->/g, ''))
.toEqual('http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever'); .toEqual('http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever');
await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') === 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>'); '<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'; message = 'http://www.opkode.com/"onmouseover="alert(1)"whatever';
await mock.sendMessage(view, message); await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop(); msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message); expect(msg.textContent).toEqual(message);
await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') === await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
...@@ -159,14 +157,12 @@ describe("XSS", function () { ...@@ -159,14 +157,12 @@ describe("XSS", function () {
message = "https://en.wikipedia.org/wiki/Ender's_Game"; message = "https://en.wikipedia.org/wiki/Ender's_Game";
await mock.sendMessage(view, message); await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop(); msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message); 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>'); 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>"; message = "<https://bugs.documentfoundation.org/show_bug.cgi?id=123737>";
await mock.sendMessage(view, message); await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop(); msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message); expect(msg.textContent).toEqual(message);
await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') === await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
...@@ -174,7 +170,6 @@ describe("XSS", function () { ...@@ -174,7 +170,6 @@ describe("XSS", function () {
message = '<http://www.opkode.com/"onmouseover="alert(1)"whatever>'; message = '<http://www.opkode.com/"onmouseover="alert(1)"whatever>';
await mock.sendMessage(view, message); await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop(); msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message); expect(msg.textContent).toEqual(message);
await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') === await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
...@@ -182,7 +177,6 @@ describe("XSS", function () { ...@@ -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` 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); await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop(); msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message); expect(msg.textContent).toEqual(message);
await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') === await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
......
...@@ -43,6 +43,7 @@ converse.plugins.add('converse-chat', { ...@@ -43,6 +43,7 @@ converse.plugins.add('converse-chat', {
api.settings.extend({ api.settings.extend({
'allow_message_corrections': 'all', 'allow_message_corrections': 'all',
'allow_message_retraction': 'all', 'allow_message_retraction': 'all',
'allow_message_styling': true,
'auto_join_private_chats': [], 'auto_join_private_chats': [],
'clear_messages_on_reconnection': false, 'clear_messages_on_reconnection': false,
'filter_by_resource': false, 'filter_by_resource': false,
......
...@@ -52,6 +52,7 @@ Strophe.addNamespace('RSM', 'http://jabber.org/protocol/rsm'); ...@@ -52,6 +52,7 @@ Strophe.addNamespace('RSM', 'http://jabber.org/protocol/rsm');
Strophe.addNamespace('SID', 'urn:xmpp:sid:0'); Strophe.addNamespace('SID', 'urn:xmpp:sid:0');
Strophe.addNamespace('SPOILER', 'urn:xmpp:spoiler:0'); Strophe.addNamespace('SPOILER', 'urn:xmpp:spoiler:0');
Strophe.addNamespace('STANZAS', 'urn:ietf:params:xml:ns:xmpp-stanzas'); Strophe.addNamespace('STANZAS', 'urn:ietf:params:xml:ns:xmpp-stanzas');
Strophe.addNamespace('STYLING', 'urn:xmpp:styling:0');
Strophe.addNamespace('VCARD', 'vcard-temp'); Strophe.addNamespace('VCARD', 'vcard-temp');
Strophe.addNamespace('VCARDUPDATE', 'vcard-temp:x:update'); Strophe.addNamespace('VCARDUPDATE', 'vcard-temp:x:update');
Strophe.addNamespace('XFORM', 'jabber:x:data'); Strophe.addNamespace('XFORM', 'jabber:x:data');
......
/** /**
* @copyright 2020, the Converse.js contributors * @copyright 2020, the Converse.js contributors
* @license Mozilla Public License (MPLv2) * @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. * @todo Other parsing helpers can be made more abstract and placed here.
*/ */
const helpers = {}; const helpers = {};
......
...@@ -441,9 +441,10 @@ const st = { ...@@ -441,9 +441,10 @@ const st = {
* @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker? * @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_marker - Is this message a XEP-0333 Chat Marker?
* @property { Boolean } is_only_emojis - Does the message body contain only emojis? * @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_spoiler - Is this a XEP-0382 spoiler message?
* @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone? * @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 { Object } encrypted - XEP-0384 encryption payload attributes
* @property { String } body - The contents of the <body> tag of the message stanza * @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 * @property { String } chat_state - The XEP-0085 chat state notification contained in this message
...@@ -489,6 +490,7 @@ const st = { ...@@ -489,6 +490,7 @@ const st = {
'is_delayed': !!delay, 'is_delayed': !!delay,
'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length, 'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length,
'is_marker': !!marker, 'is_marker': !!marker,
'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
'marker_id': marker && marker.getAttribute('id'), 'marker_id': marker && marker.getAttribute('id'),
'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
'nick': contact?.attributes?.nickname, 'nick': contact?.attributes?.nickname,
...@@ -581,9 +583,10 @@ const st = { ...@@ -581,9 +583,10 @@ const st = {
* @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker? * @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_marker - Is this message a XEP-0333 Chat Marker?
* @property { Boolean } is_only_emojis - Does the message body contain only emojis? * @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_spoiler - Is this a XEP-0382 spoiler message?
* @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone? * @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 { Object } encrypted - XEP-0384 encryption payload attributes
* @property { String } body - The contents of the <body> tag of the message stanza * @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 * @property { String } chat_state - The XEP-0085 chat state notification contained in this message
...@@ -632,6 +635,7 @@ const st = { ...@@ -632,6 +635,7 @@ const st = {
'is_headline': st.isHeadline(stanza), 'is_headline': st.isHeadline(stanza),
'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length, 'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length,
'is_marker': !!marker, 'is_marker': !!marker,
'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
'marker_id': marker && marker.getAttribute('id'), 'marker_id': marker && marker.getAttribute('id'),
'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
'receipt_id': getReceiptId(stanza), 'receipt_id': getReceiptId(stanza),
......
/**
* @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) => ['>', '&gt;'].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;
}
}
}
This diff is collapsed.
import URI from "urijs"; import { MessageText } from '../../shared/message/text.js';
import log from '@converse/headless/log'; import { api, converse } from "@converse/headless/converse-core";
import { _converse, api, converse } from "@converse/headless/converse-core";
import { convertASCII2Emoji, getEmojiMarkup, getCodePointReferences, getShortnameReferences } from "@converse/headless/converse-emoji.js";
import { directive, html } from "lit-html"; import { directive, html } from "lit-html";
import { until } from 'lit-html/directives/until.js'; 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>`;
const u = converse.env.utils;
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 { class MessageBodyRenderer {
...@@ -186,42 +28,18 @@ class MessageBodyRenderer { ...@@ -186,42 +28,18 @@ class MessageBodyRenderer {
} }
async transform () { async transform () {
const text = new MessageText(this.text); const show_images = api.settings.get('show_images_inline');
/** const offset = 0;
* Synchronous event which provides a hook for transforming a chat message's body text const text = new MessageText(
* before the default transformations have been applied. this.text,
* @event _converse#beforeMessageBodyTransformed this.model,
* @param { _converse.Message } model - The model representing the message offset,
* @param { MessageText } text - A {@link MessageText } instance. You show_images,
* 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,
() => this.scrollDownOnImageLoad(), () => this.scrollDownOnImageLoad(),
ev => this.component.showImageModal(ev) ev => this.component.showImageModal(ev)
); );
addMapURLs(text); await text.addTemplates();
await addEmojis(text); return text.payload;
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();
} }
render () { render () {
......
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)));
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment