Переглянути джерело

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
JC Brand 5 роки тому
батько
коміт
b4dafcc45b

+ 10 - 4
CHANGES.md

@@ -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.

+ 6 - 6
README.md

@@ -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
 

+ 20 - 0
docs/source/configuration.rst

@@ -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``

+ 6 - 6
index.html

@@ -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>

+ 3 - 0
sass/_chatrooms.scss

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

+ 10 - 0
sass/_core.scss

@@ -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);
 

+ 18 - 1
sass/_messages.scss

@@ -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;
+            }
+        }
     }
 }
 

+ 8 - 1
sass/_modal.scss

@@ -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 {

+ 0 - 2
spec/chatbox.js

@@ -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({

+ 1 - 1
spec/mam.js

@@ -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.');
 

+ 2 - 2
spec/messages.js

@@ -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);

+ 2 - 0
spec/muc.js

@@ -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 - 0
spec/retractions.js

@@ -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();
+            }));
+        });
+    })
+}));

+ 47 - 9
src/converse-chatview.js

@@ -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();
 

+ 45 - 21
src/converse-message-view.js

@@ -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_retracted,
+                        'extra_classes': this.getExtraMessageClasses(),
                         'is_groupchat_message': this.model.get('type') === 'groupchat',
-                        'occupant': this.model.occupant,
                         'is_me_message': this.model.isMeCommand(),
-                        'roles': roles,
+                        '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(),
-                        'extra_classes': this.getExtraMessageClasses(),
-                        'label_show': __('Show more'),
                         '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') || ''}`;

+ 136 - 11
src/converse-modal.js

@@ -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();

+ 99 - 4
src/converse-muc-views.js

@@ -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.
                  *

+ 1 - 0
src/converse-notification.js

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

+ 152 - 71
src/headless/converse-chat.js

@@ -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
+                    );
+                }
+            },
+
+            /**
+             * 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;
             },
 
             /**
-             * Determine whether the passed in message attributes represent a
+             * 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 () {

+ 11 - 5
src/headless/converse-core.js

@@ -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));

+ 0 - 1
src/headless/converse-mam.js

@@ -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.

+ 217 - 39
src/headless/converse-muc.js

@@ -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.");
+            /**
+             * 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 {
-                    return _converse.ChatBox.prototype.getErrorMessage.call(this, stanza);
+                    // 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);
+                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 &&
-                        !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) }
-                            )
-                        });
+
+                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)
                  *

+ 170 - 26
src/headless/utils/stanza.js

@@ -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 {};
+    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')
+
+                    }
+                }
+            }
         }
-        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')
+        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;
     }
 }
 

+ 1 - 1
src/templates/alert_modal.html

@@ -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>

+ 26 - 16
src/templates/message.html

@@ -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 - 0
src/templates/prompt.html

@@ -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>

+ 2 - 0
src/utils/html.js

@@ -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 = [];

+ 1 - 0
tests/runner.js

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

+ 0 - 4
webpack.html

@@ -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'],