Add support for XEP-0424 and XEP-0425

- Add support for switching ephemerality after message creation
- Move more methods from ChatBox and ChatRoom to utils/stanza.js
- Rename 'ephemeral' to 'is_ephemeral' since it's a boolean
This commit is contained in:
JC Brand 2019-09-24 15:33:41 +02:00
parent 4b3d427cff
commit b4dafcc45b
29 changed files with 1929 additions and 236 deletions

View File

@ -2,10 +2,9 @@
## 6.0.0 (Unreleased)
- #129: Add support for XEP-0156: Disovering Alternative XMPP Connection Methods. Only XML is supported for now.
- #1105: Preliminary support for storing persistent data in IndexedDB instead of localStorage
- #1691: Fix `collection.chatbox is undefined` errors
- #1772: `_converse.api.contact.add(jid, nick)` fails, says not a function
- Add support for [XEP-0424 Message Retraction](http://localhost:3080/extensions/xep-0424.html)
- Add support for [XEP-0425 Message Moderation](http://localhost:3080/extensions/xep-0425.html)
- Prevent editing of sent file uploads.
- Initial support for sending custom emojis. Currently only between Converse
instances. Still working out a wire protocol for compatibility with other clients.
To add custom emojis, edit the `emojis.json` file.
@ -14,6 +13,13 @@
- New config option [muc_mention_autocomplete_filter](https://conversejs.org/docs/html/configuration.html#muc_mention_autocomplete_filter)
- New config option [muc_mention_autocomplete_show_avatar](https://conversejs.org/docs/html/configuration.html#muc_mention_autocomplete_show_avatar)
- #129: Add support for XEP-0156: Disovering Alternative XMPP Connection Methods. Only XML is supported for now.
- #1105: Preliminary support for storing persistent data in IndexedDB instead of localStorage
- #1691: Fix `collection.chatbox is undefined` errors
- #1733: New message notifications for a minimized chat stack on top of each other
- #1757: Chats are hidden behind the controlbox on mobile
- #1772 `_converse.api.contact.add(jid, nick)` fails, says not a function
### Breaking changes
- In contrast to sessionStorage and localStorage, IndexedDB is an asynchronous database.

View File

@ -39,9 +39,9 @@ which shows you how to use the CDN (content delivery network) to quickly get a d
## Features
- Available as overlayed chat boxes or as a fullscreen application. See [inverse.chat](https://inverse.chat) for the fullscreen version.
- Custom status messages
- Desktop notifications
- A [plugin architecture](https://conversejs.org/docs/html/plugin_development.html) based on [pluggable.js](https://conversejs.github.io/pluggable.js/)
- Single-user and group chats
- Contacts and groups
- Multi-user chat rooms [XEP 45](https://xmpp.org/extensions/xep-0045.html)
- Chatroom bookmarks [XEP 48](https://xmpp.org/extensions/xep-0048.html)
- Direct invitations to chat rooms [XEP 249](https://xmpp.org/extensions/xep-0249.html)
@ -50,9 +50,7 @@ which shows you how to use the CDN (content delivery network) to quickly get a d
- In-band registration [XEP 77](https://xmpp.org/extensions/xep-0077.html)
- Roster item exchange [XEP 144](https://xmpp.org/extensions/tmp/xep-0144-1.1.html)
- Chat statuses (online, busy, away, offline)
- Custom status messages
- Typing and state notifications [XEP 85](https://xmpp.org/extensions/xep-0085.html)
- Desktop notifications
- File sharing / HTTP File Upload [XEP 363](https://xmpp.org/extensions/xep-0363.html)
- Messages appear in all connnected chat clients / Message Carbons [XEP 280](https://xmpp.org/extensions/xep-0280.html)
- Third person "/me" messages [XEP 245](https://xmpp.org/extensions/xep-0245.html)
@ -62,8 +60,10 @@ which shows you how to use the CDN (content delivery network) to quickly get a d
- Client state indication [XEP 352](https://xmpp.org/extensions/xep-0352.html)
- Last Message Correction [XEP 308](https://xmpp.org/extensions/xep-0308.html)
- OMEMO encrypted messaging [XEP 384](https://xmpp.org/extensions/xep-0384.html")
- Supports anonymous logins, see the [anonymous login demo](https://conversejs.org/demo/anonymous.html).
- Translated into 28 languages
- Anonymous logins, see the [anonymous login demo](https://conversejs.org/demo/anonymous.html)
- Message Retractions [XEP-424](https://xmpp.org/extensions/xep-0424.html)
- Message Moderation [XEP-425](https://xmpp.org/extensions/xep-0425.html)
- Translated into over 30 languages
## Integration into other frameworks

View File

@ -1475,6 +1475,26 @@ not fulfilled.
Requires the `src/converse-notification.js` plugin.
show_retraction_warning
-----------------------
* Default: ``true``
From `XEP-0424: Message Retraction <https://xmpp.org/extensions/xep-0424.html>`_:
::
Due to the federated and extensible nature of XMPP it's not possible to remove a message with
full certainty and a retraction can only be considered an unenforceable request for such removal.
Clients which don't support message retraction are not obligated to enforce the request and
people could have seen or copied the message contents already.
By default Converse shows a warning to users when they retract a message, to
inform them that they don't have a guarantee that the message will be removed
everywhere.
This warning isn't applicable to all deployments of Converse and can therefore
be turned off by setting this config variable to ``false``.
use_system_emojis
-----------------
* Default: ``true``

View File

@ -171,8 +171,8 @@
See <a href="https://inverse.chat" target="_blank" rel="noopener">inverse.chat</a> for the fullscreen version.
</li>
<li>A <a href="https://conversejs.org/docs/html/plugin_development.html" target="_blank" rel="noopener">plugin architecture</a> based on <a href="https://conversejs.github.io/pluggable.js/" target="_blank" rel="noopener">pluggable.js</a></li>
<li>Single-user and group chat</li>
<li>Contacts and groups</li>
<li>Chat statuses (online, busy, away, offline)</li>
<li>Desktop notifications</li>
<li>Multi-user chatrooms (<a href="https://xmpp.org/extensions/xep-0045.html" target="_blank" rel="noopener">XEP 45</a>)</li>
<li>Chatroom bookmarks (<a href="https://xmpp.org/extensions/xep-0048.html" target="_blank" rel="noopener">XEP 48</a>)</li>
<li>Direct invitations to chat rooms (<a href="https://xmpp.org/extensions/xep-0249.html" target="_blank" rel="noopener">XEP 249</a>)</li>
@ -180,10 +180,8 @@
<li>Service discovery (<a href="https://xmpp.org/extensions/xep-0030.html" target="_blank" rel="noopener">XEP 30</a>)</li>
<li>In-band registration (<a href="https://xmpp.org/extensions/xep-0077.html" target="_blank" rel="noopener">XEP 77</a>)</li>
<li>Roster item exchange (<a href="https://xmpp.org/extensions/xep-0144.html" target="_blank" rel="noopener">XEP 144</a>)</li>
<li>Chat statuses (online, busy, away, offline)</li>
<li>Custom status messages</li>
<li>Typing and chat state notifications (<a href="https://xmpp.org/extensions/xep-0085.html" target="_blank" rel="noopener">XEP 85</a>)</li>
<li>Desktop notifications</li>
<li>File sharing / HTTP File Upload (<a href="https://xmpp.org/extensions/xep-0363.html" target="_blank" rel="noopener">XEP 363</a>)</li>
<li>Messages appear in all connected chat clients / Message Carbons (<a href="https://xmpp.org/extensions/xep-0280.html" target="_blank" rel="noopener">XEP 280</a>)</li>
<li>Third person "/me" messages (<a href="https://xmpp.org/extensions/xep-0245.html" target="_blank" rel="noopener">XEP 245</a>)</li>
@ -193,8 +191,10 @@
<li>Client state indication (<a href="https://xmpp.org/extensions/xep-0352.html" target="_blank" rel="noopener">XEP 352</a>)</li>
<li>Last Message Correction (<a href="https://xmpp.org/extensions/xep-0308.html" target="_blank" rel="noopener">XEP 308</a>)</li>
<li>OMEMO encrypted messaging (<a href="https://xmpp.org/extensions/xep-0384.html" target="_blank" rel="noopener">XEP 384</a>)</li>
<li>Supports anonymous logins, see the <a href="https://conversejs.org/demo/anonymous.html" target="_blank" rel="noopener">anonymous login demo</a>.</li>
<li>Translated into 29 languages</li>
<li>Anonymous logins, see the <a href="https://conversejs.org/demo/anonymous.html" target="_blank" rel="noopener">anonymous login demo</a></li>
<li>Message Retractions (<a href="https://xmpp.org/extensions/xep-0424.html" target="_blank" rel="noopener">XEP 424</a>)</li>
<li>Message Moderation (<a href="https://xmpp.org/extensions/xep-0425.html" target="_blank" rel="noopener">XEP 425</a>)</li>
<li>Translated into over 30 languages</li>
</ul>
</div>
</div>

View File

@ -143,6 +143,9 @@
&.badge {
color: var(--chat-head-text-color);
}
&.chat-msg--retracted {
color: var(--subdued-color);
}
}
.disconnect-container {
margin: 1em;

View File

@ -317,6 +317,16 @@ body.converse-fullscreen {
color: var(--gray-color);
}
q {
quotes: "" "" "" "";
}
q:before {
content: open-quote;
}
q:after {
content: close-quote;
}
.modal {
background-color: rgba(0, 0, 0, 0.4);

View File

@ -39,6 +39,12 @@
}
}
&.chat-msg--retracted {
.chat-msg__message {
color: var(--subdued-color);
}
}
&.chat-info {
color: var(--chat-head-color);
font-size: var(--message-font-size);
@ -46,6 +52,9 @@
font-size: 90%;
padding: 0.17rem 1rem;
&.chat-msg--followup {
margin-left: 2.75rem;
}
&.badge {
color: var(--chat-head-text-color);
}
@ -60,6 +69,9 @@
color: var(--error-color);
font-weight: bold;
}
.q {
font-style: italic;
}
}
.chat-image {
@ -225,7 +237,7 @@
height: var(--message-font-size);
font-size: var(--message-font-size);
padding: 0;
padding-left: 0.5em;
padding-left: 0.75em;
border: none;
opacity: 0;
background: transparent;
@ -336,6 +348,11 @@
}
}
}
&.chat-info {
&.chat-msg--followup {
margin-left: 0;
}
}
}
}

View File

@ -1,8 +1,15 @@
#conversejs {
#converse-modals {
.modal-body {
margin-bottom: 2em;
.confirm {
.form-group {
p:first-child {
font-size: 110%;
font-weight: bold;
}
}
}
}
.scrollable-container {

View File

@ -720,7 +720,6 @@
await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
await test_utils.waitForRoster(_converse, 'current');
// Send a message from a different resource
spyOn(_converse, 'log');
const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
const view = await test_utils.openChatBoxFor(_converse, recipient_jid);
const msg = $msg({
@ -848,7 +847,6 @@
await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
await test_utils.waitForRoster(_converse, 'current');
// Send a message from a different resource
spyOn(_converse, 'log');
const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
const view = await test_utils.openChatBoxFor(_converse, recipient_jid);
const msg = $msg({

View File

@ -1037,7 +1037,7 @@
const view = _converse.chatboxviews.get(contact_jid);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('ephemeral')).toBe(false);
expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
expect(view.model.messages.at(0).get('type')).toBe('error');
expect(view.model.messages.at(0).get('message')).toBe('Timeout while trying to fetch archived messages.');

View File

@ -83,7 +83,7 @@
expect(textarea.value).toBe('');
const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(1);
expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
let action = view.el.querySelector('.chat-msg .chat-msg__action');
expect(action.getAttribute('title')).toBe('Edit this message');
@ -160,7 +160,7 @@
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
);
await new Promise(resolve => view.once('messageInserted', resolve));
expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(1);
expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
// Test confirmation dialog
spyOn(window, 'confirm').and.returnValue(true);

View File

@ -5232,6 +5232,7 @@
const textarea = view.el.querySelector('.chat-textarea');
textarea.value = 'Hello world';
view.onFormSubmitted(new Event('submit'));
await new Promise(resolve => view.once('messageInserted', resolve));
const stanza = u.toStanza(`
<message xmlns="jabber:client" type="error" to="troll@montague.lit/resource" from="trollbox@montague.lit">
@ -5240,6 +5241,7 @@
_converse.connection._dataRecv(test_utils.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
expect(view.el.querySelector('.chat-error').textContent.trim()).toBe(
"Your message was not delivered because you're not allowed to send messages in this groupchat.");
done();

900
spec/retractions.js Normal file
View File

@ -0,0 +1,900 @@
(function (root, factory) {
define([
"jasmine",
"mock",
"test-utils"
], factory);
} (this, function (jasmine, mock, test_utils) {
"use strict";
const { Strophe, $iq } = converse.env;
const u = converse.env.utils;
async function sendAndThenRetractMessage (_converse, view) {
view.model.sendMessage('hello world');
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
const msg_obj = view.model.messages.at(0);
const reflection_stanza = u.toStanza(`
<message xmlns="jabber:client"
from="${msg_obj.get('from')}"
to="${_converse.connection.jid}"
type="groupchat">
<msg_body>${msg_obj.get('message')}</msg_body>
<stanza-id xmlns="urn:xmpp:sid:0"
id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
by="lounge@montague.lit"/>
<origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
</message>`);
await view.model.onMessage(reflection_stanza);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract'));
retract_button.click();
await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
submit_button.click();
const sent_stanzas = _converse.connection.sent_stanzas;
return u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
}
describe("Message Retractions", function () {
describe("A groupchat message retraction", function () {
it("is not applied if it's not from the right author",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const received_stanza = u.toStanza(`
<message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
<body>Hello world</body>
<stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
</message>
`);
const view = _converse.api.chatviews.get(muc_jid);
await view.model.onMessage(received_stanza);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
const retraction_stanza = u.toStanza(`
<message type="groupchat" id='retraction-id-1' from="${muc_jid}/mallory" to="${muc_jid}/romeo">
<apply-to id="stanza-id-1" xmlns="urn:xmpp:fasten:0">
<retract xmlns="urn:xmpp:message-retract:0" />
</apply-to>
</message>
`);
spyOn(view.model, 'handleRetraction').and.callThrough();
_converse.connection._dataRecv(test_utils.createRequest(retraction_stanza));
await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
expect(view.model.handleRetraction.calls.first().returnValue).toBe(true);
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.model.messages.length).toBe(2);
expect(view.model.messages.at(1).get('retracted')).toBeTruthy();
expect(view.model.messages.at(1).get('is_ephemeral')).toBeFalsy();
expect(view.model.messages.at(1).get('dangling_retraction')).toBe(true);
expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
done();
}));
it("can be received before the message it pertains to",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
const date = (new Date()).toISOString();
const muc_jid = 'lounge@montague.lit';
await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const retraction_stanza = u.toStanza(`
<message type="groupchat" id='retraction-id-1' from="${muc_jid}/eve" to="${muc_jid}/romeo">
<apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
<retract by="${muc_jid}/eve" xmlns="urn:xmpp:message-retract:0" />
</apply-to>
</message>
`);
const view = _converse.api.chatviews.get(muc_jid);
spyOn(converse.env.log, 'warn');
spyOn(view.model, 'handleRetraction').and.callThrough();
_converse.connection._dataRecv(test_utils.createRequest(retraction_stanza));
await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
await u.waitUntil(() => view.model.messages.length === 1);
expect(view.model.handleRetraction.calls.first().returnValue).toBe(true);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('retracted')).toBeTruthy();
expect(view.model.messages.at(0).get('dangling_retraction')).toBe(true);
const received_stanza = u.toStanza(`
<message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
<body>Hello world</body>
<delay xmlns='urn:xmpp:delay' stamp='${date}'/>
<stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
<origin-id xmlns="urn:xmpp:sid:0" id="origin-id-1"/>
</message>
`);
_converse.connection._dataRecv(test_utils.createRequest(received_stanza));
await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
expect(view.el.querySelectorAll('.chat-msg').length).toBe(0);
expect(view.model.messages.length).toBe(1);
const message = view.model.messages.at(0)
expect(message.get('retracted')).toBeTruthy();
expect(message.get('dangling_retraction')).toBe(false);
expect(message.get('origin_id')).toBe('origin-id-1');
expect(message.get(`stanza_id ${muc_jid}`)).toBe('stanza-id-1');
expect(message.get('time')).toBe(date);
expect(message.get('type')).toBe('groupchat');
expect(view.model.handleRetraction.calls.all().pop().returnValue).toBe(true);
done();
}));
});
describe("A message retraction", function () {
it("can be received before the message it pertains to",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
const date = (new Date()).toISOString();
await test_utils.waitForRoster(_converse, 'current', 1);
await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
const view = await test_utils.openChatBoxFor(_converse, contact_jid);
spyOn(view.model, 'handleRetraction').and.callThrough();
const retraction_stanza = u.toStanza(`
<message id="${u.getUniqueId()}"
to="${_converse.bare_jid}"
from="${contact_jid}"
type="chat"
xmlns="jabber:client">
<apply-to id="2e972ea0-0050-44b7-a830-f6638a2595b3" xmlns="urn:xmpp:fasten:0">
<retract xmlns="urn:xmpp:message-retract:0"/>
</apply-to>
</message>
`);
const promise = new Promise(resolve => _converse.api.listen.on('messageAdded', resolve));
_converse.connection._dataRecv(test_utils.createRequest(retraction_stanza));
await u.waitUntil(() => view.model.messages.length === 1);
await promise;
const message = view.model.messages.at(0);
expect(message.get('dangling_retraction')).toBe(true);
expect(message.get('is_ephemeral')).toBe(false);
expect(message.get('retracted')).toBeTruthy();
expect(view.el.querySelectorAll('.chat-msg').length).toBe(0);
const stanza = u.toStanza(`
<message xmlns="jabber:client"
to="${_converse.bare_jid}"
type="chat"
id="2e972ea0-0050-44b7-a830-f6638a2595b3"
from="${contact_jid}">
<body>Hello world</body>
<delay xmlns='urn:xmpp:delay' stamp='${date}'/>
<markable xmlns="urn:xmpp:chat-markers:0"/>
<origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
<stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
</message>`);
_converse.connection._dataRecv(test_utils.createRequest(stanza));
await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
expect(view.model.messages.length).toBe(1);
expect(message.get('retracted')).toBeTruthy();
expect(message.get('dangling_retraction')).toBe(false);
expect(message.get('origin_id')).toBe('2e972ea0-0050-44b7-a830-f6638a2595b3');
expect(message.get('time')).toBe(date);
expect(message.get('type')).toBe('chat');
done();
}));
});
describe("A Received Chat Message", function () {
it("can be followed up by a retraction",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
await test_utils.waitForRoster(_converse, 'current', 1);
await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
const view = await test_utils.openChatBoxFor(_converse, contact_jid);
let stanza = u.toStanza(`
<message xmlns="jabber:client"
to="${_converse.bare_jid}"
type="chat"
id="29132ea0-0121-2897-b121-36638c259554"
from="${contact_jid}">
<body>😊</body>
<markable xmlns="urn:xmpp:chat-markers:0"/>
<origin-id xmlns="urn:xmpp:sid:0" id="29132ea0-0121-2897-b121-36638c259554"/>
<stanza-id xmlns="urn:xmpp:sid:0" id="kxViLhgbnNMcWv10" by="${_converse.bare_jid}"/>
</message>`);
_converse.connection._dataRecv(test_utils.createRequest(stanza));
await u.waitUntil(() => view.model.messages.length === 1);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
stanza = u.toStanza(`
<message xmlns="jabber:client"
to="${_converse.bare_jid}"
type="chat"
id="2e972ea0-0050-44b7-a830-f6638a2595b3"
from="${contact_jid}">
<body>This message will be retracted</body>
<markable xmlns="urn:xmpp:chat-markers:0"/>
<origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
<stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
</message>`);
_converse.connection._dataRecv(test_utils.createRequest(stanza));
await u.waitUntil(() => view.model.messages.length === 2);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2);
const retraction_stanza = u.toStanza(`
<message id="${u.getUniqueId()}"
to="${_converse.bare_jid}"
from="${contact_jid}"
type="chat"
xmlns="jabber:client">
<apply-to id="2e972ea0-0050-44b7-a830-f6638a2595b3" xmlns="urn:xmpp:fasten:0">
<retract xmlns="urn:xmpp:message-retract:0"/>
</apply-to>
</message>
`);
_converse.connection._dataRecv(test_utils.createRequest(retraction_stanza));
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
expect(view.model.messages.length).toBe(2);
const message = view.model.messages.at(1);
expect(message.get('retracted')).toBeTruthy();
expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message');
expect(msg_el.textContent.trim()).toBe('Mercutio has retracted this message');
expect(u.hasClass('chat-msg--followup', view.el.querySelector('.chat-msg--retracted'))).toBe(true);
done();
}));
});
describe("A Sent Chat Message", function () {
it("can be retracted by its author",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
await test_utils.waitForRoster(_converse, 'current', 1);
const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
const view = await test_utils.openChatBoxFor(_converse, contact_jid);
view.model.sendMessage('hello world');
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract'));
retract_button.click();
await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
submit_button.click();
const sent_stanzas = _converse.connection.sent_stanzas;
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
const msg_obj = view.model.messages.at(0);
const retraction_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
expect(Strophe.serialize(retraction_stanza)).toBe(
`<message id="${retraction_stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+
`<store xmlns="urn:xmpp:hints"/>`+
`<apply-to id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:fasten:0">`+
`<retract xmlns="urn:xmpp:message-retract:0"/>`+
`</apply-to>`+
`</message>`);
const message = view.model.messages.at(0);
expect(view.model.messages.length).toBe(1);
expect(message.get('retracted')).toBeTruthy();
expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message');
expect(el.textContent.trim()).toBe('Romeo Montague has retracted this message');
done();
}));
});
describe("A Received Groupchat Message", function () {
it("can be followed up by a retraction by the author",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const received_stanza = u.toStanza(`
<message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
<body>Hello world</body>
<stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
<origin-id xmlns='urn:xmpp:sid:0' id='origin-id-1' by='${muc_jid}'/>
</message>
`);
const view = _converse.api.chatviews.get(muc_jid);
await view.model.onMessage(received_stanza);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
const retraction_stanza = u.toStanza(`
<message type="groupchat" id='retraction-id-1' from="${muc_jid}/eve" to="${muc_jid}/romeo">
<apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
<retract by="${muc_jid}/eve" xmlns="urn:xmpp:message-retract:0" />
</apply-to>
</message>
`);
_converse.connection._dataRecv(test_utils.createRequest(retraction_stanza));
// We opportunistically save the message as retracted, even before receiving the retraction message
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('retracted')).toBeTruthy();
expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message');
expect(msg_el.textContent.trim()).toBe('eve has retracted this message');
expect(msg_el.querySelector('.chat-msg--retracted q')).toBe(null);
done();
}));
it("can be retracted by a moderator, with the IQ response received before the retraction message",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.api.chatviews.get(muc_jid);
const occupant = view.model.getOwnOccupant();
expect(occupant.get('role')).toBe('moderator');
const received_stanza = u.toStanza(`
<message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'>
<body>Visit this site to get free Bitcoin!</body>
<stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
</message>
`);
await view.model.onMessage(received_stanza);
await u.waitUntil(() => view.model.messages.length === 1);
expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
const reason = "This content is inappropriate for this forum!"
const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract'));
retract_button.click();
await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
const reason_input = document.querySelector('#converse-modals .modal input[name="reason"]');
reason_input.value = 'This content is inappropriate for this forum!';
const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
submit_button.click();
const sent_IQs = _converse.connection.IQ_stanzas;
const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
const message = view.model.messages.at(0);
const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
expect(Strophe.serialize(stanza)).toBe(
`<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
`<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+
`<moderate xmlns="urn:xmpp:message-moderate:0">`+
`<retract xmlns="urn:xmpp:message-retract:0"/>`+
`<reason>This content is inappropriate for this forum!</reason>`+
`</moderate>`+
`</apply-to>`+
`</iq>`);
const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'});
_converse.connection._dataRecv(test_utils.createRequest(result_iq));
// We opportunistically save the message as retracted, even before receiving the retraction message
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message');
expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo has retracted this message from mallory');
const qel = msg_el.querySelector('q');
expect(qel.textContent.trim()).toBe('This content is inappropriate for this forum!');
// The server responds with a retraction message
const retraction = u.toStanza(`
<message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
<moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
<retract xmlns='urn:xmpp:message-retract:0' />
<reason>${reason}</reason>
</moderated>
</apply-to>
</message>`);
await view.model.onMessage(retraction);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
done();
}));
it("can be retracted by a moderator, with the retraction message received before the IQ response",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.api.chatviews.get(muc_jid);
const occupant = view.model.getOwnOccupant();
expect(occupant.get('role')).toBe('moderator');
const received_stanza = u.toStanza(`
<message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'>
<body>Visit this site to get free Bitcoin!</body>
<stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
</message>
`);
await view.model.onMessage(received_stanza);
await u.waitUntil(() => view.model.messages.length === 1);
const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract'));
retract_button.click();
await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
const reason_input = document.querySelector('#converse-modals .modal input[name="reason"]');
const reason = "This content is inappropriate for this forum!"
reason_input.value = reason;
const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
submit_button.click();
const sent_IQs = _converse.connection.IQ_stanzas;
const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
const message = view.model.messages.at(0);
const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
// The server responds with a retraction message
const retraction = u.toStanza(`
<message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
<moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
<retract xmlns='urn:xmpp:message-retract:0' />
<reason>${reason}</reason>
</moderated>
</apply-to>
</message>`);
await view.model.onMessage(retraction);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
expect(msg_el.textContent).toBe('romeo has retracted this message from mallory');
const qel = view.el.querySelector('.chat-msg--retracted .chat-msg__message q');
expect(qel.textContent).toBe('This content is inappropriate for this forum!');
const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'});
_converse.connection._dataRecv(test_utils.createRequest(result_iq));
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
expect(view.model.messages.at(0).get('moderated_by')).toBe(_converse.bare_jid);
expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
done();
}));
});
describe("A Sent Groupchat Message", function () {
it("can be retracted by its author",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.api.chatviews.get(muc_jid);
const occupant = view.model.getOwnOccupant();
expect(occupant.get('role')).toBe('moderator');
occupant.save('role', 'member');
const retraction_stanza = await sendAndThenRetractMessage(_converse, view);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
const msg_obj = view.model.messages.at(0);
expect(Strophe.serialize(retraction_stanza)).toBe(
`<message id="${retraction_stanza.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
`<store xmlns="urn:xmpp:hints"/>`+
`<apply-to id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:fasten:0">`+
`<retract xmlns="urn:xmpp:message-retract:0"/>`+
`</apply-to>`+
`</message>`);
const message = view.model.messages.at(0);
expect(message.get('retracted')).toBeTruthy();
expect(message.get('is_ephemeral')).toBe(false);
const stanza_id = message.get(`stanza_id ${muc_jid}`);
// The server responds with a retraction message
const reflection = u.toStanza(`
<message type="groupchat" id="${retraction_stanza.getAttribute('id')}" from="${muc_jid}" to="${muc_jid}/romeo">
<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
<moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
<retract xmlns='urn:xmpp:message-retract:0' />
</moderated>
</apply-to>
</message>`);
spyOn(view.model, 'handleRetraction').and.callThrough();
_converse.connection._dataRecv(test_utils.createRequest(reflection));
await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('retracted')).toBeTruthy();
expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
expect(el.textContent).toBe('romeo has retracted this message');
done();
}));
it("can be retracted by its author, causing an error message in response",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.api.chatviews.get(muc_jid);
const occupant = view.model.getOwnOccupant();
expect(occupant.get('role')).toBe('moderator');
occupant.save('role', 'member');
const retraction_stanza = await sendAndThenRetractMessage(_converse, view);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('retracted')).toBeTruthy();
const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
expect(el.textContent.trim()).toBe('romeo has retracted this message');
const message = view.model.messages.at(0);
const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
// The server responds with an error message
const error = u.toStanza(`
<message type="error" id="${retraction_stanza.getAttribute('id')}" from="${muc_jid}" to="${view.model.get('jid')}/romeo">
<error by='${muc_jid}' type='auth'>
<forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
</error>
<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
<moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
<retract xmlns='urn:xmpp:message-retract:0' />
</moderated>
</apply-to>
</message>`);
_converse.connection._dataRecv(test_utils.createRequest(error));
await u.waitUntil(() => view.el.querySelectorAll('.chat-error').length === 1);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 0);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
expect(view.el.querySelectorAll('.chat-error').length).toBe(1);
const errmsg = view.el.querySelector('.chat-error');
expect(errmsg.textContent).toBe("Sorry, something went wrong while trying to retract your message.");
done();
}));
it("can be retracted by its author, causing an timeout error in response",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
_converse.STANZA_TIMEOUT = 1;
const muc_jid = 'lounge@montague.lit';
await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.api.chatviews.get(muc_jid);
const occupant = view.model.getOwnOccupant();
expect(occupant.get('role')).toBe('moderator');
occupant.save('role', 'member');
await sendAndThenRetractMessage(_converse, view);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('retracted')).toBeTruthy();
const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
expect(el.textContent.trim()).toBe('romeo has retracted this message');
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 0);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
const error_messages = view.el.querySelectorAll('.chat-error');
expect(error_messages.length).toBe(2);
expect(error_messages[0].textContent).toBe("Sorry, something went wrong while trying to retract your message.");
expect(error_messages[1].textContent).toBe("Timeout Error: No response from server");
done();
}));
});
describe("when archived", function () {
it("may be returned as a tombstone message",
mock.initConverse(
['discoInitialized'], {},
async function (done, _converse) {
await test_utils.waitForRoster(_converse, 'current', 1);
const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
await test_utils.openChatBoxFor(_converse, contact_jid);
await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
const sent_IQs = _converse.connection.IQ_stanzas;
const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
const queryid = stanza.querySelector('query').getAttribute('queryid');
const view = _converse.chatboxviews.get(contact_jid);
const first_id = u.getUniqueId();
spyOn(view.model, 'handleRetraction').and.callThrough();
const first_message = u.toStanza(`
<message id='${u.getUniqueId()}' to='${_converse.jid}'>
<result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${first_id}">
<forwarded xmlns='urn:xmpp:forward:0'>
<delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:01:15Z'/>
<message type="chat" from="${contact_jid}" to="${_converse.bare_jid}" id="message-id-0">
<origin-id xmlns='urn:xmpp:sid:0' id="origin-id-0"/>
<body>😊</body>
</message>
</forwarded>
</result>
</message>
`);
_converse.connection._dataRecv(test_utils.createRequest(first_message));
const tombstone = u.toStanza(`
<message id='${u.getUniqueId()}' to='${_converse.jid}'>
<result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${u.getUniqueId()}">
<forwarded xmlns='urn:xmpp:forward:0'>
<delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:08:25Z'/>
<message type="chat" from="${contact_jid}" to="${_converse.bare_jid}" id="message-id-1">
<origin-id xmlns='urn:xmpp:sid:0' id="origin-id-1"/>
<retracted stamp='2019-09-20T23:09:32Z' xmlns='urn:xmpp:message-retract:0'/>
</message>
</forwarded>
</result>
</message>
`);
_converse.connection._dataRecv(test_utils.createRequest(tombstone));
const last_id = u.getUniqueId();
const retraction = u.toStanza(`
<message id='${u.getUniqueId()}' to='${_converse.jid}'>
<result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${last_id}">
<forwarded xmlns='urn:xmpp:forward:0'>
<delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:08:25Z'/>
<message from="${contact_jid}" to='${_converse.bare_jid}' id='retract-message-1'>
<apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
<retract xmlns='urn:xmpp:message-retract:0'/>
</apply-to>
</message>
</forwarded>
</result>
</message>
`);
_converse.connection._dataRecv(test_utils.createRequest(retraction));
const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
.c('fin', {'xmlns': 'urn:xmpp:mam:2'})
.c('set', {'xmlns': 'http://jabber.org/protocol/rsm'})
.c('first', {'index': '0'}).t(first_id).up()
.c('last').t(last_id).up()
.c('count').t('2');
_converse.connection._dataRecv(test_utils.createRequest(iq_result));
await u.waitUntil(() => view.model.handleRetraction.calls.count() === 3);
expect(view.model.messages.length).toBe(2);
const message = view.model.messages.at(1);
expect(message.get('retracted')).toBeTruthy();
expect(message.get('is_tombstone')).toBe(true);
expect(view.model.handleRetraction.calls.first().returnValue).toBe(false);
expect(view.model.handleRetraction.calls.all()[1].returnValue).toBe(false);
expect(view.model.handleRetraction.calls.all()[2].returnValue).toBe(true);
expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
expect(el.textContent.trim()).toBe('Mercutio has retracted this message');
expect(u.hasClass('chat-msg--followup', el.parentElement)).toBe(false);
done();
}));
it("may be returned as a tombstone groupchat message",
mock.initConverse(
['discoInitialized'], {},
async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.chatboxviews.get(muc_jid);
const sent_IQs = _converse.connection.IQ_stanzas;
const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
const queryid = stanza.querySelector('query').getAttribute('queryid');
const first_id = u.getUniqueId();
const tombstone = u.toStanza(`
<message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
<result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="stanza-id">
<forwarded xmlns="urn:xmpp:forward:0">
<delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
<message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="message-id-1">
<origin-id xmlns='urn:xmpp:sid:0' id="origin-id-1"/>
<retracted stamp="2019-09-20T23:09:32Z" xmlns="urn:xmpp:message-retract:0"/>
</message>
</forwarded>
</result>
</message>
`);
spyOn(view.model, 'handleRetraction').and.callThrough();
const promise = new Promise(resolve => _converse.api.listen.once('messageAdded', resolve));
_converse.connection._dataRecv(test_utils.createRequest(tombstone));
const last_id = u.getUniqueId();
const retraction = u.toStanza(`
<message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
<result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}">
<forwarded xmlns="urn:xmpp:forward:0">
<delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
<message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="retract-message-1">
<apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
<retract xmlns="urn:xmpp:message-retract:0"/>
</apply-to>
</message>
</forwarded>
</result>
</message>
`);
_converse.connection._dataRecv(test_utils.createRequest(retraction));
const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
.c('fin', {'xmlns': 'urn:xmpp:mam:2'})
.c('set', {'xmlns': 'http://jabber.org/protocol/rsm'})
.c('first', {'index': '0'}).t(first_id).up()
.c('last').t(last_id).up()
.c('count').t('2');
_converse.connection._dataRecv(test_utils.createRequest(iq_result));
await promise;
expect(view.model.messages.length).toBe(1);
let message = view.model.messages.at(0);
expect(message.get('retracted')).toBeTruthy();
expect(message.get('is_tombstone')).toBe(true);
await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
expect(view.model.handleRetraction.calls.first().returnValue).toBe(false);
expect(view.model.handleRetraction.calls.all()[1].returnValue).toBe(true);
expect(view.model.messages.length).toBe(1);
message = view.model.messages.at(0);
expect(message.get('retracted')).toBeTruthy();
expect(message.get('is_tombstone')).toBe(true);
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
expect(el.textContent.trim()).toBe('eve has retracted this message');
done();
}));
it("may be returned as a tombstone moderated groupchat message",
mock.initConverse(
['discoInitialized', 'chatBoxesFetched'], {},
async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.chatboxviews.get(muc_jid);
const sent_IQs = _converse.connection.IQ_stanzas;
const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
const queryid = stanza.querySelector('query').getAttribute('queryid');
const first_id = u.getUniqueId();
const tombstone = u.toStanza(`
<message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
<result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="stanza-id">
<forwarded xmlns="urn:xmpp:forward:0">
<delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
<message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="message-id-1">
<moderated by="${muc_jid}/bob" stamp="2019-09-20T23:09:32Z" xmlns='urn:xmpp:message-moderate:0'>
<retracted xmlns="urn:xmpp:message-retract:0"/>
<reason>This message contains inappropriate content</reason>
</moderated>
</message>
</forwarded>
</result>
</message>
`);
spyOn(view.model, 'handleModeration').and.callThrough();
const promise = new Promise(resolve => _converse.api.listen.once('messageAdded', resolve));
_converse.connection._dataRecv(test_utils.createRequest(tombstone));
const last_id = u.getUniqueId();
const retraction = u.toStanza(`
<message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
<result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}">
<forwarded xmlns="urn:xmpp:forward:0">
<delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
<message type="groupchat" from="${muc_jid}" to="${_converse.bare_jid}" id="retract-message-1">
<apply-to id="stanza-id" xmlns="urn:xmpp:fasten:0">
<moderated by="${muc_jid}/bob" xmlns='urn:xmpp:message-moderate:0'>
<retract xmlns="urn:xmpp:message-retract:0"/>
<reason>This message contains inappropriate content</reason>
</moderated>
</apply-to>
</message>
</forwarded>
</result>
</message>
`);
_converse.connection._dataRecv(test_utils.createRequest(retraction));
const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
.c('fin', {'xmlns': 'urn:xmpp:mam:2'})
.c('set', {'xmlns': 'http://jabber.org/protocol/rsm'})
.c('first', {'index': '0'}).t(first_id).up()
.c('last').t(last_id).up()
.c('count').t('2');
_converse.connection._dataRecv(test_utils.createRequest(iq_result));
await promise;
expect(view.model.messages.length).toBe(1);
let message = view.model.messages.at(0);
expect(message.get('retracted')).toBeTruthy();
expect(message.get('is_tombstone')).toBe(true);
await u.waitUntil(() => view.model.handleModeration.calls.count() === 2);
expect(view.model.handleModeration.calls.first().returnValue).toBe(false);
expect(view.model.handleModeration.calls.all()[1].returnValue).toBe(true);
expect(view.model.messages.length).toBe(1);
message = view.model.messages.at(0);
expect(message.get('retracted')).toBeTruthy();
expect(message.get('is_tombstone')).toBe(true);
expect(message.get('moderation_reason')).toBe("This message contains inappropriate content");
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
expect(el.textContent.trim()).toBe('A moderator has retracted this message from eve');
const qel = view.el.querySelector('.chat-msg--retracted .chat-msg__message q');
expect(qel.textContent.trim()).toBe('This message contains inappropriate content');
done();
}));
});
})
}));

View File

@ -65,6 +65,7 @@ converse.plugins.add('converse-chatview', {
'auto_focus': true,
'message_limit': 0,
'show_send_button': false,
'show_retraction_warning': true,
'show_toolbar': true,
'time_format': 'HH:mm',
'visible_toolbar_buttons': {
@ -226,6 +227,7 @@ converse.plugins.add('converse-chatview', {
events: {
'change input.fileupload': 'onFileSelection',
'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
'click .chat-msg__action-retract': 'onMessageRetractButtonClicked',
'click .chatbox-navback': 'showControlBox',
'click .close-chatbox-button': 'close',
'click .new-msgs-indicator': 'viewUnreadMessages',
@ -622,8 +624,8 @@ converse.plugins.add('converse-chatview', {
return this.trigger('messageInserted', view.el);
}
}
const current_msg_date = dayjs(view.model.get('time')).toDate() || new Date(),
previous_msg_date = this.getLastMessageDate(current_msg_date);
const current_msg_date = dayjs(view.model.get('time')).toDate() || new Date();
const previous_msg_date = this.getLastMessageDate(current_msg_date);
if (previous_msg_date === null) {
this.content.insertAdjacentElement('afterbegin', view.el);
@ -649,9 +651,8 @@ converse.plugins.add('converse-chatview', {
* followup message or not.
*
* Followup messages are subsequent ones written by the same
* author with no other conversation elements inbetween and
* posted within 10 minutes of one another.
*
* author with no other conversation elements in between and
* which were posted within 10 minutes of one another.
* @private
* @method _converse.ChatBoxView#markFollowups
* @param { HTMLElement } el - The message element
@ -730,11 +731,9 @@ converse.plugins.add('converse-chatview', {
// We already have a view for this message
return;
}
if (!u.isNewMessage(message) && u.isEmptyMessage(message)) {
// Ignore archived or delayed messages without any text to show.
return message.destroy();
if (!message.get('dangling_retraction')) {
await this.showMessage(message);
}
await this.showMessage(message);
/**
* Triggered once a message has been added to a chatbox.
* @event _converse#messageAdded
@ -914,6 +913,45 @@ converse.plugins.add('converse-chatview', {
this.insertIntoTextArea('', true, false);
},
/**
* Retract one of your messages in this chat
* @private
* @method _converse.ChatBoxView#retractOwnMessage
* @param { _converse.Message } message - The message which we're retracting.
*/
retractOwnMessage(message) {
this.model.sendRetractionMessage(message);
message.save({
'retracted': (new Date()).toISOString(),
'retracted_id': message.get('origin_id'),
'is_ephemeral': true
});
},
async onMessageRetractButtonClicked (ev) {
ev.preventDefault();
const msg_el = u.ancestor(ev.target, '.message');
const msgid = msg_el.getAttribute('data-msgid');
const time = msg_el.getAttribute('data-isodate');
const message = this.model.messages.findWhere({msgid, time});
if (message.get('sender') !== 'me') {
return log.error("onMessageEditButtonClicked called for someone else's message!");
}
const retraction_warning =
__("Be aware that other XMPP/Jabber clients (and servers) may "+
"not yet support retractions and that this message may not "+
"be removed everywhere.");
const messages = [__('Are you sure you want to retract this message?')];
if (_converse.show_retraction_warning) {
messages[1] = retraction_warning;
}
const result = await _converse.api.confirm(__('Confirm'), messages);
if (result) {
this.retractOwnMessage(message);
}
},
onMessageEditButtonClicked (ev) {
ev.preventDefault();

View File

@ -21,7 +21,7 @@ import tpl_message_versions_modal from "templates/message_versions_modal.html";
import tpl_spinner from "templates/spinner.html";
import xss from "xss/dist/xss";
const { dayjs } = converse.env;
const { Strophe, dayjs } = converse.env;
const u = converse.env.utils;
@ -140,22 +140,20 @@ converse.plugins.add('converse-message-view', {
} else {
await this.renderChatMessage();
}
if (is_followup) {
u.addClass('chat-msg--followup', this.el);
}
is_followup && u.addClass('chat-msg--followup', this.el);
return this.el;
},
async onChanged (item) {
// Jot down whether it was edited because the `changed`
// attr gets removed when this.render() gets called further
// down.
// attr gets removed when this.render() gets called further down.
const edited = item.changed.edited;
if (this.model.changed.progress) {
return this.renderFileUploadProgresBar();
}
const isValidChange = prop => Object.prototype.hasOwnProperty.call(this.model.changed, prop);
if (['correcting', 'message', 'type', 'upload', 'received', 'editable'].filter(isValidChange).length) {
const props = ['moderated', 'retracted', 'correcting', 'message', 'type', 'upload', 'received', 'editable'];
if (props.filter(isValidChange).length) {
await this.debouncedRender();
}
if (edited) {
@ -243,19 +241,22 @@ converse.plugins.add('converse-message-view', {
const time = dayjs(this.model.get('time'));
const role = this.model.vcard ? this.model.vcard.get('role') : null;
const roles = role ? role.split(',') : [];
const is_retracted = this.model.get('retracted') || this.model.get('moderated') === 'retracted';
const msg = u.stringToElement(tpl_message(
Object.assign(
this.model.toJSON(), {
'__': __,
'is_groupchat_message': this.model.get('type') === 'groupchat',
'occupant': this.model.occupant,
'is_me_message': this.model.isMeCommand(),
'roles': roles,
'pretty_time': time.format(_converse.time_format),
'time': time.toISOString(),
__,
is_retracted,
'extra_classes': this.getExtraMessageClasses(),
'is_groupchat_message': this.model.get('type') === 'groupchat',
'is_me_message': this.model.isMeCommand(),
'label_show': __('Show more'),
'occupant': this.model.occupant,
'pretty_time': time.format(_converse.time_format),
'retraction_text': is_retracted ? this.getRetractionText() : null,
'roles': roles,
'time': time.toISOString(),
'username': this.model.getDisplayName()
})
));
@ -265,11 +266,13 @@ converse.plugins.add('converse-message-view', {
msg.querySelector('.chat-msg__media').innerHTML = this.transformOOBURL(url);
}
const text = this.model.getMessageText();
const msg_content = msg.querySelector('.chat-msg__text');
if (text && text !== url) {
msg_content.innerHTML = await this.transformBodyText(text);
await u.renderImageURLs(_converse, msg_content);
if (!is_retracted) {
const text = this.model.getMessageText();
const msg_content = msg.querySelector('.chat-msg__text');
if (text && text !== url) {
msg_content.innerHTML = await this.transformBodyText(text);
await u.renderImageURLs(_converse, msg_content);
}
}
if (this.model.get('type') !== 'headline') {
this.renderAvatar(msg);
@ -292,6 +295,25 @@ converse.plugins.add('converse-message-view', {
return this.replaceElement(msg);
},
getRetractionText () {
const username = this.model.getDisplayName();
let retraction_text = __('A message by %1$s has been retracted', username);
if (this.model.get('type') === 'groupchat') {
const retracted_by_mod = this.model.get('moderated_by');
if (retracted_by_mod) {
const chatbox = this.model.collection.chatbox;
if (!this.model.mod) {
this.model.mod =
chatbox.occupants.findOccupant({'jid': retracted_by_mod}) ||
chatbox.occupants.findOccupant({'nick': Strophe.getResourceFromJid(retracted_by_mod)});
}
const modname = this.model.mod ? this.model.mod.getDisplayName() : 'A moderator';
retraction_text = __('%1$s has retracted this message from %2$s', modname , username);
}
}
return retraction_text;
},
renderErrorMessage () {
const msg = u.stringToElement(
tpl_info(Object.assign(this.model.toJSON(), {
@ -304,8 +326,8 @@ converse.plugins.add('converse-message-view', {
renderChatStateNotification () {
let text;
const from = this.model.get('from'),
name = this.model.getDisplayName();
const from = this.model.get('from');
const name = this.model.getDisplayName();
if (this.model.get('chat_state') === _converse.COMPOSING) {
if (this.model.get('sender') === 'me') {
@ -354,8 +376,10 @@ converse.plugins.add('converse-message-view', {
},
getExtraMessageClasses () {
let extra_classes = this.model.get('is_delayed') && 'delayed' || '';
const is_retracted = this.model.get('retracted') || this.model.get('moderated') === 'retracted';
const extra_classes = [
...(this.model.get('is_delayed') ? ['delayed'] : []), ...(is_retracted ? ['chat-msg--retracted'] : [])
];
if (this.model.get('type') === 'groupchat') {
if (this.model.occupant) {
extra_classes += ` ${this.model.occupant.get('role') || ''} ${this.model.occupant.get('affiliation') || ''}`;

View File

@ -12,6 +12,7 @@ import converse from "@converse/headless/converse-core";
import { isString } from "lodash";
import tpl_alert from "templates/alert.html";
import tpl_alert_modal from "templates/alert_modal.html";
import tpl_prompt from "templates/prompt.html";
const { Backbone, sizzle } = converse.env;
const u = converse.env.utils;
@ -21,6 +22,7 @@ converse.plugins.add('converse-modal', {
initialize () {
const { _converse } = this;
const { __ } = _converse;
_converse.BootstrapModal = Backbone.VDOMView.extend({
@ -79,18 +81,69 @@ converse.plugins.add('converse-modal', {
}
});
_converse.Alert = _converse.BootstrapModal.extend({
_converse.Confirm = _converse.BootstrapModal.extend({
events: {
'submit .confirm': 'onConfimation'
},
initialize () {
this.confirmation = u.getResolveablePromise();
_converse.BootstrapModal.prototype.initialize.apply(this, arguments);
this.listenTo(this.model, 'change', this.render)
this.el.addEventListener('closed.bs.modal', () => this.confirmation.reject(), false);
},
toHTML () {
return tpl_prompt(Object.assign({__}, this.model.toJSON()));
},
afterRender () {
if (!this.close_handler_registered) {
this.el.addEventListener('closed.bs.modal', () => {
if (!this.confirmation.isResolved) {
this.confirmation.reject()
}
}, false);
this.close_handler_registered = true;
}
},
onConfimation (ev) {
ev.preventDefault();
this.confirmation.resolve(true);
this.modal.hide();
}
});
_converse.Prompt = _converse.Confirm.extend({
toHTML () {
return tpl_prompt(Object.assign({__}, this.model.toJSON()));
},
onConfimation (ev) {
ev.preventDefault();
const form_data = new FormData(ev.target);
this.confirmation.resolve(form_data.get('reason'));
this.modal.hide();
}
});
_converse.Alert = _converse.BootstrapModal.extend({
initialize () {
_converse.BootstrapModal.prototype.initialize.apply(this, arguments);
this.listenTo(this.model, 'change', this.render)
},
toHTML () {
return tpl_alert_modal(this.model.toJSON());
return tpl_alert_modal(
Object.assign({__}, this.model.toJSON()));
}
});
/************************ BEGIN Event Listeners ************************/
_converse.api.listen.on('afterTearDown', () => {
if (!_converse.chatboxviews) {
return;
@ -104,40 +157,112 @@ converse.plugins.add('converse-modal', {
/************************ BEGIN API ************************/
// We extend the default converse.js API to add methods specific to MUC chat rooms.
let alert;
let alert, prompt, confirm;
Object.assign(_converse.api, {
/**
* Show a confirm modal to the user.
* @method _converse.api.confirm
* @param { String } title - The header text for the confirmation dialog
* @param { (String[]|String) } messages - The text to show to the user
* @returns { Promise } A promise which resolves with true or false
*/
async confirm (title, messages=[]) {
if (isString(messages)) {
messages = [messages];
}
if (confirm === undefined) {
const model = new Backbone.Model({
'title': title,
'messages': messages,
'type': 'confirm'
})
confirm = new _converse.Confirm({model});
} else {
confirm.model.set({
'title': title,
'messages': messages,
'type': 'confirm'
});
}
confirm.show();
try {
return await confirm.confirmation;
} catch (e) {
return false;
}
},
/**
* Show a prompt modal to the user.
* @method _converse.api.prompt
* @param { String } title - The header text for the prompt
* @param { (String[]|String) } messages - The prompt text to show to the user
* @param { String } placeholder - The placeholder text for the prompt input
* @returns { Promise } A promise which resolves with the text provided by the
* user or `false` if the user canceled the prompt.
*/
async prompt (title, messages=[], placeholder='') {
if (isString(messages)) {
messages = [messages];
}
if (prompt === undefined) {
const model = new Backbone.Model({
'title': title,
'messages': messages,
'placeholder': placeholder,
'type': 'prompt'
})
prompt = new _converse.Prompt({model});
} else {
prompt.model.set({
'title': title,
'messages': messages,
'type': 'prompt'
});
}
prompt.show();
try {
return await prompt.confirmation;
} catch (e) {
return false;
}
},
/**
* Show an alert modal to the user.
* @method _converse.api.alert
* @param { ('info'|'warn'|'error') } type - The type of alert.
* @returns { String } title - The header text for the alert.
* @returns { (String[]|String) } messages - The alert text to show to the user.
* @param { String } title - The header text for the alert.
* @param { (String[]|String) } messages - The alert text to show to the user.
*/
alert (type, title, messages) {
if (isString(messages)) {
messages = [messages];
}
let level;
if (type === 'error') {
type = 'alert-danger';
level = 'alert-danger';
} else if (type === 'info') {
type = 'alert-info';
level = 'alert-info';
} else if (type === 'warn') {
type = 'alert-warning';
level = 'alert-warning';
}
if (alert === undefined) {
const model = new Backbone.Model({
'title': title,
'messages': messages,
'type': type
'level': level,
'type': 'alert'
})
alert = new _converse.Alert({'model': model});
alert = new _converse.Alert({model});
} else {
alert.model.set({
'title': title,
'messages': messages,
'type': type
'level': level
});
}
alert.show();

View File

@ -41,7 +41,6 @@ import tpl_rooms_results from "templates/rooms_results.html";
import tpl_spinner from "templates/spinner.html";
import xss from "xss/dist/xss";
const { Backbone, Strophe, sizzle, _, $iq, $pres } = converse.env;
const u = converse.env.utils;
@ -108,6 +107,7 @@ converse.plugins.add('converse-muc-views', {
'auto_list_rooms': false,
'cache_muc_messages': true,
'locked_muc_nickname': false,
'show_retraction_warning': true,
'muc_disable_slash_commands': false,
'muc_show_join_leave': true,
'muc_show_join_leave_status': true,
@ -630,6 +630,7 @@ converse.plugins.add('converse-muc-views', {
events: {
'change input.fileupload': 'onFileSelection',
'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
'click .chat-msg__action-retract': 'onMessageRetractButtonClicked',
'click .chatbox-navback': 'showControlBox',
'click .close-chatbox-button': 'close',
'click .configure-chatroom-button': 'getAndRenderConfigurationForm',
@ -724,8 +725,7 @@ converse.plugins.add('converse-muc-views', {
},
renderChatArea () {
/* Render the UI container in which groupchat messages will appear.
*/
// Render the UI container in which groupchat messages will appear.
if (this.el.querySelector('.chat-area') === null) {
const container_el = this.el.querySelector('.chatroom-body');
container_el.insertAdjacentHTML(
@ -811,6 +811,101 @@ converse.plugins.add('converse-muc-views', {
return _converse.ChatBoxView.prototype.onKeyUp.call(this, ev);
},
async onMessageRetractButtonClicked (ev) {
ev.preventDefault();
const msg_el = u.ancestor(ev.target, '.message');
const msgid = msg_el.getAttribute('data-msgid');
const time = msg_el.getAttribute('data-isodate');
const message = this.model.messages.findWhere({msgid, time});
const retraction_warning =
__("Be aware that other XMPP/Jabber clients (and servers) may "+
"not yet support retractions and that this message may not "+
"be removed everywhere.");
if (message.get('sender') === 'me') {
const messages = [__('Are you sure you want to retract this message?')];
if (_converse.show_retraction_warning) {
messages[1] = retraction_warning;
}
const result = await _converse.api.confirm(__('Confirm'), messages);
if (result) {
this.retractOwnMessage(message);
}
} else {
let messages = [
__('You are about to retract this message.'),
__('You may optionally include a message, explaining the reason for the retraction.')
];
if (_converse.show_retraction_warning) {
messages = [messages[0], retraction_warning, messages[1]]
}
const reason = await _converse.api.prompt(
__('Message Retraction'),
messages,
__('Optional reason')
);
if (reason !== false) {
this.retractOtherMessage(message, reason);
}
}
},
/**
* Retract one of your messages in this groupchat.
* @private
* @method _converse.ChatRoomView#retractOwnMessage
* @param { _converse.Message } message - The message which we're retracting.
*/
retractOwnMessage(message) {
this.model.sendRetractionMessage(message)
.catch(e => {
message.save({
'retracted': undefined,
'retracted_id': undefined
});
const errmsg = __('Sorry, something went wrong while trying to retract your message.');
if (u.isErrorStanza(e)) {
this.showErrorMessage(errmsg);
} else {
this.showErrorMessage(errmsg);
this.showErrorMessage(e.message);
}
log.error(e);
});
message.save({
'retracted': (new Date()).toISOString(),
'retracted_id': message.get('origin_id')
});
},
/**
* Retract someone else's message in this groupchat.
* @private
* @method _converse.ChatRoomView#retractOtherMessage
* @param { _converse.Message } message - The message which we're retracting.
* @param { string } [reason] - The reason for retracting the message.
*/
async retractOtherMessage (message, reason) {
const result = await this.model.sendRetractionIQ(message, reason);
if (result === null) {
const err_msg = __(`A timeout occurred while trying to retract the message`);
_converse.api.alert('error', __('Error'), err_msg);
_converse.log(err_msg, Strophe.LogLevel.WARN);
} else if (u.isErrorStanza(result)) {
const err_msg = __(`Sorry, you're not allowed to retract this message.`);
_converse.api.alert('error', __('Error'), err_msg);
_converse.log(err_msg, Strophe.LogLevel.WARN);
_converse.log(result, Strophe.LogLevel.WARN);
} else {
message.save({
'moderated': 'retracted',
'moderated_by': _converse.bare_jid,
'moderated_id': message.get('msgid'),
'moderation_reason': reason
});
}
},
showModeratorToolsModal (affiliation) {
if (!this.verifyRoles(['moderator'])) {
return;
@ -2193,7 +2288,7 @@ converse.plugins.add('converse-muc-views', {
* @namespace _converse.api.roomviews
* @memberOf _converse.api
*/
'roomviews': {
roomviews: {
/**
* Retrieves a groupchat (aka chatroom) view. The chat should already be open.
*

View File

@ -121,6 +121,7 @@ converse.plugins.add('converse-notification', {
_converse.areDesktopNotificationsEnabled = function () {
return _converse.supports_html5_notification &&
_converse.show_desktop_notifications &&
Notification.permission === "granted";
};

View File

@ -1,10 +1,10 @@
import "./utils/stanza";
import { get, isObject, isString, propertyOf } from "lodash";
import { get, isObject, isString, pick } from "lodash";
import converse from "./converse-core";
import filesize from "filesize";
import log from "./log";
import stanza_utils from "./utils/stanza";
const { $msg, Backbone, Strophe, dayjs, sizzle, utils } = converse.env;
const { $msg, Backbone, Strophe, sizzle, utils } = converse.env;
const u = converse.env.utils;
@ -21,7 +21,7 @@ converse.plugins.add('converse-chat', {
*
* NB: These plugins need to have already been loaded via require.js.
*/
dependencies: ["stanza-utils", "converse-chatboxes", "converse-disco"],
dependencies: ["converse-chatboxes", "converse-disco"],
initialize () {
/* The initialize function gets called as soon as the plugin is
@ -29,7 +29,6 @@ converse.plugins.add('converse-chat', {
*/
const { _converse } = this;
const { __ } = _converse;
const { stanza_utils } = _converse;
// Configuration values for this plugin
// ====================================
@ -75,7 +74,7 @@ converse.plugins.add('converse-chat', {
return {
'msgid': u.getUniqueId(),
'time': (new Date()).toISOString(),
'ephemeral': false
'is_ephemeral': false
};
},
@ -86,17 +85,36 @@ converse.plugins.add('converse-chat', {
ModelWithContact.prototype.initialize.apply(this, arguments);
this.setRosterContact(Strophe.getBareJidFromJid(this.get('from')));
}
if (this.get('file')) {
this.on('change:put', this.uploadFile, this);
}
if (this.isEphemeral()) {
window.setTimeout(this.safeDestroy.bind(this), 10000);
}
this.setTimerForEphemeralMessage();
await _converse.api.trigger('messageInitialized', this, {'Synchronous': true});
this.initialized.resolve();
},
/**
* Sets an auto-destruct timer for this message, if it's is_ephemeral.
* @private
* @method _converse.Message#setTimerForEphemeralMessage
* @returns { Boolean } - Indicates whether the message is
* ephemeral or not, and therefore whether the timer was set or not.
*/
setTimerForEphemeralMessage () {
const setTimer = () => {
this.ephemeral_timer = window.setTimeout(this.safeDestroy.bind(this), 10000);
}
if (this.isEphemeral()) {
setTimer();
return true;
} else {
this.on('change:is_ephemeral',
() => this.isEphemeral() ? setTimer() : clearTimeout(this.ephemeral_timer)
);
return false;
}
},
safeDestroy () {
try {
this.destroy()
@ -110,7 +128,7 @@ converse.plugins.add('converse-chat', {
},
isEphemeral () {
return this.isOnlyChatStateNotification() || this.get('ephemeral');
return this.get('is_ephemeral') || u.isOnlyChatStateNotification(this);
},
getDisplayName () {
@ -171,7 +189,7 @@ converse.plugins.add('converse-chat', {
return this.save({
'type': 'error',
'message': __("Sorry, could not determine upload URL."),
'ephemeral': true
'is_ephemeral': true
});
}
const slot = stanza.querySelector('slot');
@ -184,7 +202,7 @@ converse.plugins.add('converse-chat', {
return this.save({
'type': 'error',
'message': __("Sorry, could not determine file upload URL."),
'ephemeral': true
'is_ephemeral': true
});
}
},
@ -223,7 +241,7 @@ converse.plugins.add('converse-chat', {
'type': 'error',
'upload': _converse.FAILURE,
'message': message,
'ephemeral': true
'is_ephemeral': true
});
};
xhr.open('PUT', this.get('put'), true);
@ -338,17 +356,21 @@ converse.plugins.add('converse-chat', {
const message = await this.getDuplicateMessage(stanza);
if (message) {
this.updateMessage(message, original_stanza);
} else {
if (
!this.handleReceipt (stanza, from_jid) &&
!this.handleChatMarker(stanza, from_jid)
} else if (
!this.handleReceipt (stanza, from_jid) &&
!this.handleChatMarker(stanza, from_jid)
) {
const attrs = await this.getMessageAttributesFromStanza(stanza, original_stanza);
if (this.handleRetraction(attrs)) {
return;
}
this.setEditable(attrs, attrs.time, stanza);
if (attrs['chat_state'] ||
attrs['retracted'] || // Retraction received *before* the message
!u.isEmptyMessage(attrs)
) {
const attrs = await this.getMessageAttributesFromStanza(stanza, original_stanza);
this.setEditable(attrs, attrs.time, stanza);
if (attrs['chat_state'] || !u.isEmptyMessage(attrs)) {
const msg = this.correctMessage(attrs) || this.messages.create(attrs);
this.incrementUnreadMsgCounter(msg);
}
const msg = this.handleCorrection(attrs) || this.messages.create(attrs);
this.incrementUnreadMsgCounter(msg);
}
}
},
@ -517,28 +539,90 @@ converse.plugins.add('converse-chat', {
return true;
},
retractMessage (attrs) {
if (!attrs.moderated !== 'retracted' && !attrs.retracted) {
return;
isSameUser (jid1, jid2) {
return u.isSameBareJID(jid1, jid2);
},
/**
* Looks whether we already have a retraction for this
* incoming message. If so, it's considered "dangling" because it
* probably hasn't been applied to anything yet, given that the
* relevant message is only coming in now.
* @private
* @method _converse.ChatBox#findDanglingRetraction
* @param { object } attrs - Attributes representing a received
* message, as returned by {@link stanza_utils.getMessageAttributesFromStanza}
* @returns { _converse.Message }
*/
findDanglingRetraction (attrs) {
if (!attrs.origin_id || !this.messages.length) {
return null;
}
const message = this.messages.findWhere({'msgid': attrs.replaced_id, 'from': attrs.from});
if (!message) {
return;
// Only look for dangling retractions if there are newer
// messages than this one, since retractions come after.
if (this.messages.last().get('time') > attrs.time) {
// Search from latest backwards
const messages = Array.from(this.messages.models);
messages.reverse();
return messages.find(
({attributes}) =>
attributes.retracted_id === attrs.origin_id &&
attributes.from === attrs.from &&
!attributes.moderated_by
);
}
},
/**
* Determine whether the passed in message attributes represent a
* Handles message retraction based on the passed in attributes.
* @private
* @method _converse.ChatBox#handleRetraction
* @param { object } attrs - Attributes representing a received
* message, as returned by {@link stanza_utils.getMessageAttributesFromStanza}
* @returns { Boolean } Returns `true` or `false` depending on
* whether a message was retracted or not.
*/
handleRetraction (attrs) {
const RETRACTION_ATTRIBUTES = ['retracted', 'retracted_id'];
if (attrs.retracted) {
if (attrs.is_tombstone) {
return false;
}
const message = this.messages.findWhere({'origin_id': attrs.retracted_id, 'from': attrs.from});
if (!message) {
attrs['dangling_retraction'] = true;
this.messages.create(attrs);
return true;
}
message.save(pick(attrs, RETRACTION_ATTRIBUTES));
return true;
} else {
// Check if we have dangling retraction
const message = this.findDanglingRetraction(attrs);
if (message) {
const retraction_attrs = pick(message.attributes, RETRACTION_ATTRIBUTES);
const new_attrs = Object.assign({'dangling_retraction': false}, attrs, retraction_attrs);
delete new_attrs['id']; // Delete id, otherwise a new cache entry gets created
message.save(new_attrs);
return true;
}
}
return false;
},
/**
* Determines whether the passed in message attributes represent a
* message which corrects a previously received message, or an
* older message which has already been corrected.
* In both cases, update the corrected message accordingly.
* @private
* @method _converse.ChatBox#correctMessage
* @method _converse.ChatBox#handleCorrection
* @param { object } attrs - Attributes representing a received
* message, as returned by
* {@link _converse.ChatBox.getMessageAttributesFromStanza}
* message, as returned by {@link stanza_utils.getMessageAttributesFromStanza}
* @returns { _converse.Message|undefined } Returns the corrected
* message or `undefined` if not applicable.
*/
correctMessage (attrs) {
handleCorrection (attrs) {
if (!attrs.replaced_id || !attrs.from) {
return;
}
@ -604,6 +688,30 @@ converse.plugins.add('converse-chat', {
});
},
/**
* Sends a message stanza to retract a message in this chat
* @private
* @method _converse.ChatBox#sendRetractionMessage
* @param { _converse.Message } message - The message which we're retracting.
*/
sendRetractionMessage (message) {
const origin_id = message.get('origin_id');
if (!origin_id) {
throw new Error("Can't retract message without a XEP-0359 Origin ID");
}
const msg = $msg({
'id': u.getUniqueId(),
'to': this.get('jid'),
'type': "chat"
})
.c('store', {xmlns: Strophe.NS.HINTS}).up()
.c("apply-to", {
'id': origin_id,
'xmlns': Strophe.NS.FASTEN
}).c('retract', {xmlns: Strophe.NS.RETRACT})
return _converse.connection.send(msg);
},
sendMarker(to_jid, id, type) {
const stanza = $msg({
'from': _converse.connection.jid,
@ -849,7 +957,7 @@ converse.plugins.add('converse-chat', {
this.messages.create({
'message': __("Sorry, looks like file upload is not supported by your server."),
'type': 'error',
'ephemeral': true
'is_ephemeral': true
});
return;
}
@ -861,7 +969,7 @@ converse.plugins.add('converse-chat', {
this.messages.create({
'message': __("Sorry, looks like file upload is not supported by your server."),
'type': 'error',
'ephemeral': true
'is_ephemeral': true
});
return;
}
@ -871,7 +979,7 @@ converse.plugins.add('converse-chat', {
'message': __('The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.',
file.name, filesize(max_file_size)),
'type': 'error',
'ephemeral': true
'is_ephemeral': true
});
} else {
const attrs = Object.assign(
@ -890,46 +998,19 @@ converse.plugins.add('converse-chat', {
},
/**
* Parses a passed in message stanza and returns an object
* of attributes.
* Parses a passed in message stanza and returns an object of attributes.
* @private
* @method _converse.ChatBox#getMessageAttributesFromStanza
* @param { XMLElement } stanza - The message stanza
* @param { XMLElement } delay - The <delay> node from the stanza, if there was one.
* @param { XMLElement } original_stanza - The original stanza, that contains the
* message stanza, if it was contained, otherwise it's the message stanza itself.
* @returns { Object }
*/
async getMessageAttributesFromStanza (stanza, original_stanza) {
const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
const text = stanza_utils.getMessageBody(stanza) || undefined;
const chat_state = stanza.getElementsByTagName(_converse.COMPOSING).length && _converse.COMPOSING ||
stanza.getElementsByTagName(_converse.PAUSED).length && _converse.PAUSED ||
stanza.getElementsByTagName(_converse.INACTIVE).length && _converse.INACTIVE ||
stanza.getElementsByTagName(_converse.ACTIVE).length && _converse.ACTIVE ||
stanza.getElementsByTagName(_converse.GONE).length && _converse.GONE;
return Object.assign(
{
'chat_state': chat_state,
'is_archived': stanza_utils.isArchived(original_stanza),
'is_delayed': !!delay,
'is_single_emoji': text ? await u.isSingleEmoji(text) : false,
'message': text,
'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
'references': stanza_utils.getReferences(stanza),
'subject': propertyOf(stanza.querySelector('subject'))('textContent'),
'thread': propertyOf(stanza.querySelector('thread'))('textContent'),
'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(),
'type': stanza.getAttribute('type')
},
stanza_utils.getStanzaIDs(original_stanza),
stanza_utils.getSenderAttributes(stanza, this),
stanza_utils.getOutOfBandAttributes(stanza),
stanza_utils.getMessageFasteningAttributes(stanza),
stanza_utils.getSpoilerAttributes(stanza),
stanza_utils.getCorrectionAttributes(stanza, original_stanza)
);
getMessageAttributesFromStanza (stanza, original_stanza) {
// XXX: Eventually we want to get rid of this pass-through
// method but currently we still need it because converse-omemo
// overrides it.
return stanza_utils.getMessageAttributesFromStanza(stanza, original_stanza, this, _converse);
},
maybeShow () {

View File

@ -37,16 +37,19 @@ Strophe.addNamespace('CARBONS', 'urn:xmpp:carbons:2');
Strophe.addNamespace('CHATSTATES', 'http://jabber.org/protocol/chatstates');
Strophe.addNamespace('CSI', 'urn:xmpp:csi:0');
Strophe.addNamespace('DELAY', 'urn:xmpp:delay');
Strophe.addNamespace('FASTEN', 'urn:xmpp:fasten:0');
Strophe.addNamespace('FORWARD', 'urn:xmpp:forward:0');
Strophe.addNamespace('HINTS', 'urn:xmpp:hints');
Strophe.addNamespace('HTTPUPLOAD', 'urn:xmpp:http:upload:0');
Strophe.addNamespace('IDLE', 'urn:xmpp:idle:1');
Strophe.addNamespace('MAM', 'urn:xmpp:mam:2');
Strophe.addNamespace('MODERATE', 'urn:xmpp:message-moderate:0');
Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick');
Strophe.addNamespace('OMEMO', 'eu.siacs.conversations.axolotl');
Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob');
Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub');
Strophe.addNamespace('REGISTER', 'jabber:iq:register');
Strophe.addNamespace('RETRACT', 'urn:xmpp:message-retract:0');
Strophe.addNamespace('ROSTERX', 'http://jabber.org/protocol/rosterx');
Strophe.addNamespace('RSM', 'http://jabber.org/protocol/rsm');
Strophe.addNamespace('SID', 'urn:xmpp:sid:0');
@ -92,8 +95,7 @@ const CORE_PLUGINS = [
'converse-rsm',
'converse-smacks',
'converse-status',
'converse-vcard',
'stanza-utils'
'converse-vcard'
];
@ -103,7 +105,7 @@ const CORE_PLUGINS = [
* @global
* @namespace _converse
*/
// XXX: Strictly speaking _converse is not a global, but we need to set it as
// Strictly speaking _converse is not a global, but we need to set it as
// such to get JSDoc to create the correct document site strucure.
const _converse = {
'templates': {},
@ -142,6 +144,10 @@ class TimeoutError extends Error {}
_converse.TimeoutError = TimeoutError;
class IllegalMessage extends Error {}
_converse.IllegalMessage = IllegalMessage;
// Make converse pluggable
pluggable.enable(_converse, '_converse', 'pluggable');
@ -187,7 +193,7 @@ _converse.LOGOUT = 'logout';
_converse.OPENED = 'opened';
_converse.PREBIND = 'prebind';
_converse.IQ_TIMEOUT = 20000;
_converse.STANZA_TIMEOUT = 10000;
_converse.CONNECTION_STATUS = {
0: 'ERROR',
@ -1694,7 +1700,7 @@ _converse.api = {
* or is rejected when we receive an `error` stanza.
*/
sendIQ (stanza, timeout, reject=true) {
timeout = timeout || _converse.IQ_TIMEOUT;
timeout = timeout || _converse.STANZA_TIMEOUT;
let promise;
if (reject) {
promise = new Promise((resolve, reject) => _converse.connection.sendIQ(stanza, resolve, reject, timeout));

View File

@ -55,7 +55,6 @@ converse.plugins.add('converse-mam', {
});
const MAMEnabledChat = {
/**
* Fetches messages that might have been archived *after*
* the last archived message in our local cache.

View File

@ -11,7 +11,7 @@
*/
import "./converse-disco";
import "./converse-emoji";
import { clone, get, intersection, invoke, isElement, isObject, isString, uniq, zipObject } from "lodash";
import { clone, get, intersection, invoke, isElement, isObject, isString, pick, uniq, zipObject } from "lodash";
import converse from "./converse-core";
import log from "./log";
import muc_utils from "./utils/muc";
@ -252,9 +252,7 @@ converse.plugins.add('converse-muc', {
if (this.get('file')) {
this.on('change:put', this.uploadFile, this);
}
if (this.isEphemeral()) {
window.setTimeout(this.safeDestroy.bind(this), 10000);
} else {
if (!this.setTimerForEphemeralMessage()) {
this.setOccupant();
this.setVCard();
}
@ -510,9 +508,8 @@ converse.plugins.add('converse-muc', {
},
removeHandlers () {
/* Remove the presence and message handlers that were
* registered for this groupchat.
*/
// Remove the presence and message handlers that were
// registered for this groupchat.
if (this.message_handler) {
if (_converse.connection) {
_converse.connection.deleteHandler(this.message_handler);
@ -571,15 +568,96 @@ converse.plugins.add('converse-muc', {
return this;
},
/**
* Sends a message stanza to the XMPP server and expects a reflection
* or error message within a specific timeout period.
* @private
* @method _converse.ChatRoom#sendTimedMessage
* @param { _converse.Message|XMLElement } message
* @returns { Promise<XMLElement>|Promise<_converse.TimeoutError> } Returns a promise
* which resolves with the reflected message stanza or rejects
* with an error stanza or with a {@link _converse.TimeoutError}.
*/
sendTimedMessage (el) {
if (typeof(el.tree) === "function") {
el = el.tree();
}
let id = el.getAttribute('id');
if (!id) { // inject id if not found
id = this.getUniqueId("sendIQ");
el.setAttribute("id", id);
}
const promise = u.getResolveablePromise();
const timeoutHandler = _converse.connection.addTimedHandler(
_converse.STANZA_TIMEOUT,
() => {
_converse.connection.deleteHandler(handler);
promise.reject(new _converse.TimeoutError("Timeout Error: No response from server"));
return false;
}
);
const handler = _converse.connection.addHandler(stanza => {
timeoutHandler && _converse.connection.deleteTimedHandler(timeoutHandler);
if (stanza.getAttribute('type') === 'groupchat') {
promise.resolve(stanza);
} else {
promise.reject(stanza);
}
}, null, 'message', ['error', 'groupchat'], id);
_converse.api.send(el)
return promise;
},
/**
* Sends a message stanza to retract a message in this groupchat.
* @private
* @method _converse.ChatRoom#sendRetractionMessage
* @param { _converse.Message } message - The message which we're retracting.
*/
sendRetractionMessage (message) {
const origin_id = message.get('origin_id');
if (!origin_id) {
throw new Error("Can't retract message without a XEP-0359 Origin ID");
}
const msg = $msg({
'id': u.getUniqueId(),
'to': this.get('jid'),
'type': "groupchat"
})
.c('store', {xmlns: Strophe.NS.HINTS}).up()
.c("apply-to", {
'id': origin_id,
'xmlns': Strophe.NS.FASTEN
}).c('retract', {xmlns: Strophe.NS.RETRACT});
return this.sendTimedMessage(msg);
},
/**
* Sends an IQ stanza to the XMPP server to retract a message in this groupchat.
* @private
* @method _converse.ChatRoom#sendRetractionIQ
* @param { _converse.Message } message - The message which we're retracting.
* @param { string } [reason] - The reason for retracting the message.
*/
sendRetractionIQ (message, reason) {
const iq = $iq({'to': this.get('jid'), 'type': "set"})
.c("apply-to", {
'id': message.get(`stanza_id ${this.get('jid')}`),
'xmlns': Strophe.NS.FASTEN
}).c('moderate', {xmlns: Strophe.NS.MODERATE})
.c('retract', {xmlns: Strophe.NS.RETRACT}).up()
.c('reason').t(reason);
return _converse.api.sendIQ(iq, null, false);
},
/**
* Sends an IQ stanza to the XMPP server to destroy this groupchat. Not
* to be confused with the {@link _converse.ChatRoom#destroy}
* method, which simply removes the room from the local browser storage cache.
* @private
* @method _converse.ChatRoom#sendDestroyIQ
* @param { string } [reason] - The reason for destroying the groupchat
* @param { string } [new_jid] - The JID of the new groupchat which
* replaces this one.
* @param { string } [reason] - The reason for destroying the groupchat.
* @param { string } [new_jid] - The JID of the new groupchat which replaces this one.
*/
sendDestroyIQ (reason, new_jid) {
const destroy = $build("destroy");
@ -1320,6 +1398,38 @@ converse.plugins.add('converse-muc', {
}
},
/**
* Given two JIDs, which can be either user JIDs or MUC occupant JIDs,
* determine whether they belong to the same user.
* @private
* @method _converse.ChatRoom#isSameUser
* @param { String } jid1
* @param { String } jid2
* @returns { Boolean }
*/
isSameUser (jid1, jid2) {
const bare_jid1 = Strophe.getBareJidFromJid(jid1);
const bare_jid2 = Strophe.getBareJidFromJid(jid2);
const resource1 = Strophe.getResourceFromJid(jid1);
const resource2 = Strophe.getResourceFromJid(jid2);
if (u.isSameBareJID(jid1, jid2)) {
if (bare_jid1 === this.get('jid')) {
// MUC JIDs
return resource1 === resource2;
} else {
return true;
}
} else {
const occupant1 = (bare_jid1 === this.get('jid')) ?
this.occupants.findOccupant({'nick': resource1}) :
this.occupants.findOccupant({'jid': bare_jid1});
const occupant2 = (bare_jid2 === this.get('jid')) ?
this.occupants.findOccupant({'nick': resource2}) :
this.occupants.findOccupant({'jid': bare_jid2});
return occupant1 === occupant2;
}
},
/**
* Handle a subject change and return `true` if so.
@ -1460,14 +1570,89 @@ converse.plugins.add('converse-muc', {
return _converse.ChatBox.prototype.shouldShowErrorMessage.call(this, stanza);
},
getErrorMessage (stanza) {
if (sizzle(`forbidden[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) {
return __("Your message was not delivered because you're not allowed to send messages in this groupchat.");
} else if (sizzle(`not-acceptable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) {
return __("Your message was not delivered because you're not present in the groupchat.");
} else {
return _converse.ChatBox.prototype.getErrorMessage.call(this, stanza);
/**
* Looks whether we already have a moderation message for this
* incoming message. If so, it's considered "dangling" because
* it probably hasn't been applied to anything yet, given that
* the relevant message is only coming in now.
* @private
* @method _converse.ChatRoom#findDanglingModeration
* @param { object } attrs - Attributes representing a received
* message, as returned by {@link stanza_utils.getMessageAttributesFromStanza}
* @returns { _converse.ChatRoomMessage }
*/
findDanglingModeration (attrs) {
if (!this.messages.length) {
return null;
}
// Only look for dangling moderation if there are newer
// messages than this one, since moderation come after.
if (this.messages.last().get('time') > attrs.time) {
// Search from latest backwards
const messages = Array.from(this.messages.models);
const stanza_id = attrs[`stanza_id ${this.get('jid')}`];
if (!stanza_id) {
return null;
}
messages.reverse();
return messages.find(
({attributes}) =>
attributes.moderation === 'retraction' &&
attributes.moderated_id === stanza_id &&
attributes.moderated_by
);
}
},
/**
* Handles message moderation based on the passed in attributes.
* @private
* @method _converse.ChatRoom#handleModeration
* @param { object } attrs - Attributes representing a received
* message, as returned by {@link stanza_utils.getMessageAttributesFromStanza}
* @returns { Boolean } Returns `true` or `false` depending on
* whether a message was moderated or not.
*/
handleModeration (attrs) {
const MODERATION_ATTRIBUTES = [
'moderated',
'moderated_by',
'moderated_id',
'moderation_reason'
];
if (attrs.moderated === 'retracted') {
const query = {};
const key = `stanza_id ${this.get('jid')}`;
query[key] = attrs.moderated_id;
const message = this.messages.findWhere(query);
if (!message) {
attrs['dangling_moderation'] = true;
this.messages.create(attrs);
return true;
}
message.save(pick(attrs, MODERATION_ATTRIBUTES));
return true;
} else {
// Check if we have dangling moderation message
const message = this.findDanglingModeration(attrs);
if (message) {
const moderation_attrs = pick(message.attributes, MODERATION_ATTRIBUTES);
const new_attrs = Object.assign({'dangling_moderation': false}, attrs, moderation_attrs);
delete new_attrs['id']; // Delete id, otherwise a new cache entry gets created
message.save(new_attrs);
return true;
}
}
return false;
},
createMessageObject (attrs) {
return new Promise((success, reject) => {
this.messages.create(
attrs,
{ success, 'error': (m, e) => reject(e) }
)
});
},
/**
@ -1477,21 +1662,17 @@ converse.plugins.add('converse-muc', {
* @param { XMLElement } stanza - The message stanza.
*/
async onMessage (stanza) {
const original_stanza = stanza;
const bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length;
if (bare_forward) {
if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length) {
return log.warn('onMessage: Ignoring unencapsulated forwarded groupchat message');
}
const is_carbon = u.isCarbonMessage(stanza);
if (is_carbon) {
// XEP-280: groupchat messages SHOULD NOT be carbon copied, so we're discarding it.
if (u.isCarbonMessage(stanza)) {
return log.warn(
'onMessage: Ignoring XEP-0280 "groupchat" message carbon, '+
'according to the XEP groupchat messages SHOULD NOT be carbon copied'
);
}
const is_mam = u.isMAMMessage(stanza);
if (is_mam) {
const original_stanza = stanza;
if (u.isMAMMessage(stanza)) {
if (original_stanza.getAttribute('from') === this.get('jid')) {
const selector = `[xmlns="${Strophe.NS.MAM}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`;
stanza = sizzle(selector, stanza).pop();
@ -1499,7 +1680,6 @@ converse.plugins.add('converse-muc', {
return log.warn(`onMessage: Ignoring alleged MAM groupchat message from ${stanza.getAttribute('from')}`);
}
}
this.createInfoMessages(stanza);
this.fetchFeaturesIfConfigurationChanged(stanza);
@ -1510,20 +1690,18 @@ converse.plugins.add('converse-muc', {
if (message || stanza_utils.isReceipt(stanza) || stanza_utils.isChatMarker(stanza)) {
return _converse.api.trigger('message', {'stanza': original_stanza});
}
const attrs = await this.getMessageAttributesFromStanza(stanza, original_stanza);
this.setEditable(attrs, attrs.time);
if (attrs.nick &&
!this.subjectChangeHandled(attrs) &&
!this.ignorableCSN(attrs) &&
(attrs['chat_state'] || !u.isEmptyMessage(attrs))) {
const msg = this.correctMessage(attrs) ||
await new Promise((success, reject) => {
this.messages.create(
attrs,
{ success, 'erorr': (m, e) => reject(e) }
)
});
const attrs = await this.getMessageAttributesFromStanza(stanza, original_stanza);
if (this.handleRetraction(attrs) ||
this.handleModeration(attrs) ||
this.subjectChangeHandled(attrs) ||
this.ignorableCSN(attrs)) {
return _converse.api.trigger('message', {'stanza': original_stanza});
}
this.setEditable(attrs, attrs.time);
if (attrs.nick && (attrs.is_tombstone || u.isNewMessage(attrs) || !u.isEmptyMessage(attrs))) {
const msg = this.handleCorrection(attrs) || await this.createMessageObject(attrs);
this.incrementUnreadMsgCounter(msg);
}
_converse.api.trigger('message', {'stanza': original_stanza, 'chatbox': this});
@ -1539,7 +1717,7 @@ converse.plugins.add('converse-muc', {
const attrs = {
'type': 'error',
'message': text,
'ephemeral': true
'is_ephemeral': true
}
this.messages.create(attrs);
}
@ -2121,7 +2299,7 @@ converse.plugins.add('converse-muc', {
* @namespace _converse.api.rooms
* @memberOf _converse.api
*/
'rooms': {
rooms: {
/**
* Creates a new MUC chatroom (aka groupchat)
*

View File

@ -1,5 +1,7 @@
import * as strophe from 'strophe.js/src/core';
import { get, propertyOf } from "lodash";
import dayjs from 'dayjs';
import log from '@converse/headless/log';
import sizzle from 'sizzle';
import u from '@converse/headless/utils/core';
@ -38,19 +40,19 @@ const stanza_utils = {
* Extract the XEP-0359 stanza IDs from the passed in stanza
* and return a map containing them.
* @private
* @method _converse.stanza_utils#getStanzaIDs
* @method stanza_utils#getStanzaIDs
* @param { XMLElement } stanza - The message stanza
* @returns { Object }
*/
getStanzaIDs (stanza) {
getStanzaIDs (stanza, original_stanza) {
const attrs = {};
const stanza_ids = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza);
if (stanza_ids.length) {
stanza_ids.forEach(s => (attrs[`stanza_id ${s.getAttribute('by')}`] = s.getAttribute('id')));
}
const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop();
const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop();
if (result) {
const by_jid = stanza.getAttribute('from');
const by_jid = original_stanza.getAttribute('from');
attrs[`stanza_id ${by_jid}`] = result.getAttribute('id');
}
@ -64,35 +66,91 @@ const stanza_utils = {
return attrs;
},
/**
* Parses a passed in message stanza and returns an object of known attributes related to
* XEP-0422 Message Fastening.
* @private
* @method _converse.stanza_utils#getMessageFasteningAttributes
/** @method stanza_utils#getModerationAttributes
* @param { XMLElement } stanza - The message stanza
* @param { XMLElement } original_stanza - The original stanza, that contains the
* message stanza, if it was contained, otherwise it's the message stanza itself.
* @param { _converse.ChatRoom } room - The MUC in which the moderation stanza is received.
* @returns { Object }
*/
getMessageFasteningAttributes (stanza) {
const substanza = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
if (substanza === null) {
return {};
}
const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, substanza).pop();
if (moderated) {
const retracted = !!sizzle(`retract[xmlns="${Strophe.NS.RETRACT}"]`, moderated).length;
return {
'moderated': retracted ? 'retracted' : 'unknown',
'moderated_by': moderated.get('by'),
'moderated_reason': get(moderated.querySelector('reason'), 'textContent')
getModerationAttributes (stanza, original_stanza, room) {
const fastening = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
if (fastening) {
const applies_to_id = fastening.getAttribute('id');
const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, fastening).pop();
if (moderated) {
const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT}"]`, moderated).pop();
if (retracted) {
const from = stanza.getAttribute('from');
if (from !== room.get('jid')) {
log.warn("getModerationAttributes: ignore moderation stanza that's not from the MUC!");
log.error(original_stanza);
return {};
}
return {
'moderated': 'retracted',
'moderated_by': moderated.getAttribute('by'),
'moderated_id': applies_to_id,
'moderation_reason': get(moderated.querySelector('reason'), 'textContent')
}
}
}
} else {
const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE}"]`, stanza).pop();
if (tombstone) {
const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, tombstone).pop();
if (retracted) {
return {
'is_tombstone': true,
'retracted': tombstone.getAttribute('stamp'),
'moderated_by': tombstone.getAttribute('by'),
'moderation_reason': get(tombstone.querySelector('reason'), 'textContent')
}
}
}
}
return {};
},
/**
* @method stanza_utils#getRetractionAttributes
* @param { XMLElement } stanza - The message stanza
* @param { XMLElement } original_stanza - The original stanza, that contains the
* message stanza, if it was contained, otherwise it's the message stanza itself.
* @returns { Object }
*/
getRetractionAttributes (stanza, original_stanza) {
const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
if (fastening) {
const applies_to_id = fastening.getAttribute('id');
const retracted = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, fastening).pop();
if (retracted) {
const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString();
return {
'retracted': time,
'retracted_id': applies_to_id
}
}
} else {
const tombstone = sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
if (tombstone) {
return {
'retracted': tombstone.getAttribute('stamp'),
'is_tombstone': true
}
}
}
return {};
},
getReferences (stanza) {
const text = propertyOf(stanza.querySelector('body'))('textContent');
return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => {
const begin = ref.getAttribute('begin'),
end = ref.getAttribute('end');
const begin = ref.getAttribute('begin');
const end = ref.getAttribute('end');
return {
'begin': begin,
'end': end,
@ -105,8 +163,7 @@ const stanza_utils = {
getSenderAttributes (stanza, chatbox, _converse) {
const type = stanza.getAttribute('type');
if (type === 'groupchat') {
if (u.isChatRoom(chatbox)) {
const from = stanza.getAttribute('from');
const nick = Strophe.unescapeNode(Strophe.getResourceFromJid(from));
return {
@ -152,20 +209,107 @@ const stanza_utils = {
return {};
},
getCorrectionAttributes (stanza) {
getCorrectionAttributes (stanza, original_stanza) {
const el = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop();
if (el) {
const replaced_id = el.getAttribute('id');
const msgid = replaced_id;
if (replaced_id) {
const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString();
return {
msgid,
replaced_id,
'edited': new Date().toISOString()
'edited': time
}
}
}
return {};
},
getErrorMessage (stanza, is_muc, _converse) {
const { __ } = _converse;
if (is_muc) {
if (sizzle(`forbidden[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) {
return __("Your message was not delivered because you're not allowed to send messages in this groupchat.");
} else if (sizzle(`not-acceptable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) {
return __("Your message was not delivered because you're not present in the groupchat.");
}
}
const error = stanza.querySelector('error');
return propertyOf(error.querySelector('text'))('textContent') ||
__('Sorry, an error occurred:') + ' ' + error.innerHTML;
},
/**
* Given a message stanza, return the text contained in its body.
* @private
* @method stanza_utils#getMessageBody
* @param { XMLElement } stanza
* @param { Boolean } is_muc
* @param { _converse } _converse
*/
getMessageBody (stanza, is_muc, _converse) {
const type = stanza.getAttribute('type');
if (type === 'error') {
return stanza_utils.getErrorMessage(stanza, is_muc, _converse);
} else {
const body = stanza.querySelector('body');
if (body) {
return body.textContent.trim();
}
}
},
getChatState (stanza) {
return stanza.getElementsByTagName('composing').length && 'composing' ||
stanza.getElementsByTagName('paused').length && 'paused' ||
stanza.getElementsByTagName('inactive').length && 'inactive' ||
stanza.getElementsByTagName('active').length && 'active' ||
stanza.getElementsByTagName('gone').length && 'gone';
},
/**
* Parses a passed in message stanza and returns an object of attributes.
* @private
* @method stanza_utils#getMessageAttributesFromStanza
* @param { XMLElement } stanza - The message stanza
* @param { XMLElement } original_stanza - The original stanza, that contains the
* message stanza, if it was contained, otherwise it's the message stanza itself.
* @param { _converse.ChatBox|_converse.ChatRoom } chatbox
* @param { _converse } _converse
* @returns { Object }
*/
async getMessageAttributesFromStanza (stanza, original_stanza, chatbox, _converse) {
const is_muc = u.isChatRoom(chatbox);
let attrs = Object.assign(
stanza_utils.getStanzaIDs(stanza, original_stanza),
stanza_utils.getRetractionAttributes(stanza, original_stanza),
is_muc ? stanza_utils.getModerationAttributes(stanza, original_stanza, chatbox) : {},
);
const text = stanza_utils.getMessageBody(stanza, is_muc, _converse) || undefined;
const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
attrs = Object.assign(
{
'chat_state': stanza_utils.getChatState(stanza),
'is_archived': stanza_utils.isArchived(original_stanza),
'is_delayed': !!delay,
'is_single_emoji': text ? await u.isOnlyEmojis(text) : false,
'message': text,
'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
'references': stanza_utils.getReferences(stanza),
'subject': propertyOf(stanza.querySelector('subject'))('textContent'),
'thread': propertyOf(stanza.querySelector('thread'))('textContent'),
'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(),
'type': stanza.getAttribute('type')
},
attrs,
stanza_utils.getSenderAttributes(stanza, chatbox, _converse),
stanza_utils.getOutOfBandAttributes(stanza),
stanza_utils.getSpoilerAttributes(stanza),
stanza_utils.getCorrectionAttributes(stanza, original_stanza)
)
return attrs;
}
}

View File

@ -1,7 +1,7 @@
<div class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header {{{o.type}}}">
<div class="modal-header {{{o.level}}}">
<h5 class="modal-title">{{{o.title}}}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>

View File

@ -15,27 +15,37 @@
</span>
<div class="chat-msg__body chat-msg__body--{{{o.type}}} {{{o.received ? 'chat-msg__body--received' : '' }}} {{{o.is_delayed ? 'chat-msg__body--delayed' : '' }}}">
<div class="chat-msg__message">
{[ if (o.is_spoiler) { ]}
<div class="chat-msg__spoiler-hint">
<span class="spoiler-hint">{{{o.spoiler_hint}}}</span>
<a class="badge badge-info spoiler-toggle" data-toggle-state="closed" href="#"><i class="fa fa-eye"></i>{{{o.label_show}}}</a>
</div>
{[ if (o.is_retracted) { ]}
<div>{{{o.retraction_text}}}</div>
{[ if (o.moderation_reason) { ]}<q class="chat-msg--retracted__reason">{{{o.moderation_reason}}}</q>{[ } ]}
{[ } else { ]}
{[ if (o.is_spoiler) { ]}
<div class="chat-msg__spoiler-hint">
<span class="spoiler-hint">{{{o.spoiler_hint}}}</span>
<a class="badge badge-info spoiler-toggle" data-toggle-state="closed" href="#"><i class="fa fa-eye"></i>{{{o.label_show}}}</a>
</div>
{[ } ]}
{[ if (o.subject) { ]}
<div class="chat-msg__subject">{{{ o.subject }}}</div>
{[ } ]}
<div class="chat-msg__text
{[ if (o.is_single_emoji) { ]} chat-msg__text--larger{[ } ]}
{[ if (o.is_spoiler) { ]} spoiler collapsed{[ } ]}"><!-- message gets added here via renderMessage --></div>
<div class="chat-msg__media"></div>
{[ } ]}
{[ if (o.subject) { ]}
<div class="chat-msg__subject">{{{ o.subject }}}</div>
{[ } ]}
<div class="chat-msg__text
{[ if (o.is_single_emoji) { ]} chat-msg__text--larger{[ } ]}
{[ if (o.is_spoiler) { ]} spoiler collapsed{[ } ]}"><!-- message gets added here via renderMessage --></div>
<div class="chat-msg__media"></div>
</div>
{[ if (o.received && !o.is_me_message && !o.is_groupchat_message) { ]} <span class="fa fa-check chat-msg__receipt"></span> {[ } ]}
{[ if (o.edited) { ]} <i title="{{{o.__('This message has been edited')}}}" class="fa fa-edit chat-msg__edit-modal"></i> {[ } ]}
{[ if (o.editable) { ]}
<div class="chat-msg__actions">
<div class="chat-msg__actions">
{[ if (o.editable) { ]}
<button class="chat-msg__action chat-msg__action-edit fa fa-pencil-alt" title="{{{o.__('Edit this message')}}}"></button>
</div>
{[ } ]}
{[ } ]}
<!-- FIXME -->
{[ if ((o.sender === 'me' || o.is_groupchat_message) && true) { ]}
<button class="chat-msg__action chat-msg__action-retract fa fa-trash-alt" title="{{{o.__('Retract this message')}}}"></button>
{[ } ]}
</div>
</div>
</div>
</div>

30
src/templates/prompt.html Normal file
View File

@ -0,0 +1,30 @@
<div class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header {{{o.level}}}">
<h5 class="modal-title">{{{o.title}}}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<form class="converse-form converse-form--modal confirm" action="#">
<div class="form-group">
{[o.messages.forEach(function (message) { ]}
<p>{{{message}}}</p>
{[ }) ]}
</div>
{[ if (o.type === 'prompt') { ]}
<div class="form-group">
<input type="text" name="reason" class="form-control" placeholder="{{{o.placeholder}}}"/>
</div>
{[ } ]}
<div class="form-group">
<button type="submit" class="btn btn-primary">{{{o.__('OK')}}}</button>
<input type="button" class="btn btn-secondary" data-dismiss="modal" value="{{{o.__('Cancel')}}}"/>
</div>
</form>
</div>
</div>
</div>
</div>

View File

@ -293,6 +293,8 @@ u.ancestor = function (el, selector) {
* Return the element's siblings until one matches the selector.
* @private
* @method u#nextUntil
* @param { HTMLElement } el
* @param { String } selector
*/
u.nextUntil = function (el, selector) {
const matches = [];

View File

@ -55,6 +55,7 @@ var specs = [
"spec/user-details-modal",
"spec/messages",
"spec/muc_messages",
"spec/retractions",
"spec/muc",
"spec/modtools",
"spec/room_registration",

View File

@ -18,17 +18,13 @@
});
converse.initialize({
auto_away: 300,
auto_login: true,
auto_register_muc_nickname: true,
bosh_service_url: 'http://chat.example.org:5380/http-bind/',
debug: true,
enable_smacks: true,
i18n: 'en',
jid: 'klaus.dresner@chat.example.org',
message_archiving: 'always',
muc_domain: 'conference.chat.example.org',
muc_respect_autojoin: true,
password: 'secret',
view_mode: 'fullscreen',
websocket_url: 'ws://chat.example.org:5380/xmpp-websocket',
whitelisted_plugins: ['converse-debug'],