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

test: Add test case for retraction received before the message it pertains to

JC Brand 6 місяців тому
батько
коміт
56c909ba2d

+ 4 - 2
karma.conf.js

@@ -53,6 +53,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/chatview/tests/actions.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/chatbox.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/corrections.js", type: 'module' },
+      { pattern: "src/plugins/chatview/tests/deprecated-retractions.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/emojis.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/http-file-upload.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/markers.js", type: 'module' },
@@ -65,11 +66,11 @@ module.exports = function(config) {
       { pattern: "src/plugins/chatview/tests/messages.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/oob.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/receipts.js", type: 'module' },
+      { pattern: "src/plugins/chatview/tests/retractions.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/spoilers.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/styling.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/unreads.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/xss.js", type: 'module' },
-      { pattern: "src/plugins/chatview/tests/retractions.js", type: 'module' },
       { pattern: "src/plugins/controlbox/tests/controlbox.js", type: 'module' },
       { pattern: "src/plugins/controlbox/tests/login.js", type: 'module' },
       { pattern: "src/plugins/headlines-view/tests/headline.js", type: 'module' },
@@ -82,6 +83,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/muc-views/tests/component.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/corrections.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/csn.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/deprecated-retractions.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/disco.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/emojis.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/hats.js", type: 'module' },
@@ -100,10 +102,10 @@ module.exports = function(config) {
       { pattern: "src/plugins/muc-views/tests/muc-list-modal.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc-mentions.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc-messages.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/muc-private-messages.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc-registration.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/mute.js", type: 'module' },
-      { pattern: "src/plugins/muc-views/tests/muc-private-messages.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/nickname.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/occupants-filter.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/occupants.js", type: 'module' },

+ 22 - 13
src/headless/plugins/muc/parsers.js

@@ -81,34 +81,34 @@ function getJIDFromMUCUserData(stanza) {
  *  message stanza, if it was contained, otherwise it's the message stanza itself.
  * @returns {Object}
  */
-function getModerationAttributes(stanza) {
+function getDeprecatedModerationAttributes(stanza) {
     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();
+            const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT0}"]`, moderated).pop();
             if (retracted) {
                 return {
-                    'editable': false,
-                    'moderated': 'retracted',
-                    'moderated_by': moderated.getAttribute('by'),
-                    'moderated_id': applies_to_id,
-                    'moderation_reason': moderated.querySelector('reason')?.textContent,
+                    editable: false,
+                    moderated: 'retracted',
+                    moderated_by: moderated.getAttribute('by'),
+                    moderated_id: applies_to_id,
+                    moderation_reason: 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();
+            const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT0}"]`, tombstone).pop();
             if (retracted) {
                 return {
-                    'editable': false,
-                    'is_tombstone': true,
-                    'moderated_by': tombstone.getAttribute('by'),
-                    'retracted': tombstone.getAttribute('stamp'),
-                    'moderation_reason': tombstone.querySelector('reason')?.textContent,
+                    editable: false,
+                    is_tombstone: true,
+                    moderated_by: tombstone.getAttribute('by'),
+                    retracted: tombstone.getAttribute('stamp'),
+                    moderation_reason: tombstone.querySelector('reason')?.textContent,
                 };
             }
         }
@@ -116,6 +116,15 @@ function getModerationAttributes(stanza) {
     return {};
 }
 
+/**
+ * @param {Element} stanza - The message stanza
+ *  message stanza, if it was contained, otherwise it's the message stanza itself.
+ * @returns {Object}
+ */
+function getModerationAttributes(stanza) {
+    return getDeprecatedModerationAttributes(stanza);
+}
+
 /**
  * @param {Element} stanza
  * @param {'presence'|'message'} type

+ 20 - 26
src/headless/shared/actions.js

@@ -3,7 +3,7 @@ import { Strophe, $msg } from 'strophe.js';
 import api from './api/index.js';
 import converse from './api/public.js';
 
-const u = converse.env.utils;
+const { u, stx } = converse.env;
 
 /**
  * Reject an incoming message by replying with an error message of type "cancel".
@@ -19,10 +19,8 @@ export function rejectMessage(stanza, text) {
             'id': stanza.getAttribute('id'),
         })
             .c('error', { 'type': 'cancel' })
-            .c('not-allowed', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' })
-            .up()
-            .c('text', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' })
-            .t(text)
+            .c('not-allowed', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' }).up()
+            .c('text', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' }).t(text)
     );
     log.warn(`Rejecting message stanza with the following reason: ${text}`);
     log.warn(stanza);
@@ -58,10 +56,8 @@ export function sendReceiptStanza(to_jid, id) {
         'to': to_jid,
         'type': 'chat',
     })
-        .c('received', { 'xmlns': Strophe.NS.RECEIPTS, 'id': id })
-        .up()
-        .c('store', { 'xmlns': Strophe.NS.HINTS })
-        .up();
+        .c('received', { 'xmlns': Strophe.NS.RECEIPTS, 'id': id }).up()
+        .c('store', { 'xmlns': Strophe.NS.HINTS }).up();
     api.send(receipt_stanza);
 }
 
@@ -82,10 +78,8 @@ export function sendChatState(jid, chat_state) {
                 'to': jid,
                 'type': 'chat',
             })
-                .c(chat_state, { 'xmlns': Strophe.NS.CHATSTATES })
-                .up()
-                .c('no-store', { 'xmlns': Strophe.NS.HINTS })
-                .up()
+                .c(chat_state, { 'xmlns': Strophe.NS.CHATSTATES }).up()
+                .c('no-store', { 'xmlns': Strophe.NS.HINTS }).up()
                 .c('no-permanent-store', { 'xmlns': Strophe.NS.HINTS })
         );
     }
@@ -95,22 +89,22 @@ export function sendChatState(jid, chat_state) {
  * Sends a message stanza to retract a message in this chat
  * @param {string} jid
  * @param {import('../plugins/chat/message').default} message - The message which we're retracting.
+ * @param {string} retraction_id - Unique ID for the retraction message
  */
-export function sendRetractionMessage(jid, message) {
+export function sendRetractionMessage(jid, message, retraction_id) {
     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': 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 api.connection.get().send(msg);
+    const stanza = stx`
+        <message id="${retraction_id}"
+                 to="${jid}"
+                 type="chat"
+                 xmlns="jabber:client">
+            <retract id="${origin_id}" xmlns="${Strophe.NS.RETRACT}"/>
+            <body>/me retracted a message</body>
+            <store xmlns="${Strophe.NS.HINTS}"/>
+            <fallback xmlns="${Strophe.NS.FALLBACK}" for="${Strophe.NS.RETRACT}" />
+        </message>`;
+    return api.connection.get().send(stanza);
 }

+ 3 - 1
src/headless/shared/constants.js

@@ -76,6 +76,7 @@ Strophe.addNamespace('CHATSTATES', 'http://jabber.org/protocol/chatstates');
 Strophe.addNamespace('CSI', 'urn:xmpp:csi:0');
 Strophe.addNamespace('DELAY', 'urn:xmpp:delay');
 Strophe.addNamespace('EME', 'urn:xmpp:eme:0');
+Strophe.addNamespace('FALLBACK', 'urn:xmpp:fallback:0');
 Strophe.addNamespace('FASTEN', 'urn:xmpp:fasten:0');
 Strophe.addNamespace('FORWARD', 'urn:xmpp:forward:0');
 Strophe.addNamespace('HINTS', 'urn:xmpp:hints');
@@ -94,7 +95,8 @@ Strophe.addNamespace('RAI', 'urn:xmpp:rai:0');
 Strophe.addNamespace('RECEIPTS', 'urn:xmpp:receipts');
 Strophe.addNamespace('REFERENCE', 'urn:xmpp:reference:0');
 Strophe.addNamespace('REGISTER', 'jabber:iq:register');
-Strophe.addNamespace('RETRACT', 'urn:xmpp:message-retract:0');
+Strophe.addNamespace('RETRACT', 'urn:xmpp:message-retract:1');
+Strophe.addNamespace('RETRACT0', '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');

+ 17 - 2
src/headless/shared/model-with-messages.js

@@ -335,11 +335,12 @@ export default function ModelWithMessages(BaseModel) {
          * @param {Message} message - The message which we're retracting.
          */
         retractOwnMessage(message) {
-            sendRetractionMessage(this.get('jid'), message);
+            const retraction_id = u.getUniqueId();
+            sendRetractionMessage(this.get('jid'), message, retraction_id);
             message.save({
                 'retracted': new Date().toISOString(),
                 'retracted_id': message.get('origin_id'),
-                'retraction_id': message.get('id'),
+                'retraction_id': retraction_id,
                 'is_ephemeral': true,
                 'editable': false,
             });
@@ -854,6 +855,20 @@ export default function ModelWithMessages(BaseModel) {
                 }
                 message.save(pick(attrs, RETRACTION_ATTRIBUTES));
                 return true;
+            } else if (attrs.retract_id) {
+                // Handle direct retract element
+                const message = this.messages.findWhere({ 'origin_id': attrs.retract_id, 'from': attrs.from });
+                if (!message) {
+                    attrs['dangling_retraction'] = true;
+                    await this.createMessage(attrs);
+                    return true;
+                }
+                message.save({
+                    'retracted': new Date().toISOString(),
+                    'retracted_id': attrs.retract_id,
+                    'editable': false
+                });
+                return true;
             } else {
                 // Check if we have dangling retraction
                 const message = this.findDanglingRetraction(attrs);

+ 34 - 11
src/headless/shared/parsers.js

@@ -143,33 +143,56 @@ export function getEncryptionAttributes (stanza) {
  * @param {Element} stanza - The message stanza
  * @param {Element} original_stanza - The original stanza, that contains the
  *  message stanza, if it was contained, otherwise it's the message stanza itself.
- * @returns {Object}
+ * @returns {import('./types').RetractionAttrs | {}}
  */
-export function getRetractionAttributes (stanza, original_stanza) {
+export function getDeprecatedRetractionAttributes (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();
+        const retracted = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT0}"]`, 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 {
-                'editable': false,
-                'retracted': time,
-                'retracted_id': applies_to_id
+                editable: false,
+                retracted: time,
+                retracted_id: applies_to_id
             };
         }
+    }
+    return {};
+}
+
+/**
+ * @param {Element} stanza - The message stanza
+ * @param {Element} original_stanza - The original stanza, that contains the
+ *  message stanza, if it was contained, otherwise it's the message stanza itself.
+ * @returns {import('./types').RetractionAttrs | {}}
+ */
+export function getRetractionAttributes (stanza, original_stanza) {
+    const retraction = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
+    if (retraction) {
+        const delay = sizzle(`> delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
+        const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString();
+        return {
+            editable: false,
+            retracted: time,
+            retracted_id: retraction.getAttribute('id')
+        };
     } else {
-        const tombstone = sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
+        const tombstone =
+            sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop() ||
+            sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT0}"]`, stanza).pop();
         if (tombstone) {
             return {
-                'editable': false,
-                'is_tombstone': true,
-                'retracted': tombstone.getAttribute('stamp')
+                editable: false,
+                is_tombstone: true,
+                retracted: tombstone.getAttribute('stamp'),
+                retraction_id: tombstone.getAttribute('id')
             };
         }
     }
-    return {};
+    return getDeprecatedRetractionAttributes(stanza, original_stanza);
 }
 
 /**

+ 8 - 0
src/headless/shared/types.ts

@@ -13,6 +13,14 @@ type EncryptionPayloadAttrs = {
     device_id: string;
 };
 
+export type RetractionAttrs = {
+    editable: boolean;
+    is_tombstone?: boolean;
+    retracted: string;
+    retracted_id?: string; // ID of the message being retracted
+    retraction_id?: string; // ID of the retraction message
+}
+
 export type EncryptionAttrs = {
     encrypted?: EncryptionPayloadAttrs; //  XEP-0384 encryption payload attributes
     is_encrypted: boolean;

+ 54 - 0
src/plugins/chatview/tests/deprecated-retractions.js

@@ -0,0 +1,54 @@
+
+/*global mock, converse */
+
+const { Strophe, u, stx, dayjs } = converse.env;
+
+describe('A received chat message', function () {
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    it(
+        'can be followed up with a deprecated retraction',
+        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+            await mock.waitForRoster(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit';
+            const view = await mock.openChatBoxFor(_converse, contact_jid);
+
+            const received_stanza = stx`
+                <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.api.connection.get()._dataRecv(mock.createRequest(received_stanza));
+            await u.waitUntil(() => view.model.messages.length === 1);
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+
+            const retraction_stanza = stx`
+                <message id="${u.getUniqueId()}"
+                        to="${_converse.bare_jid}"
+                        from="${contact_jid}"
+                        type="chat"
+                        xmlns="jabber:client">
+                    <apply-to id="29132ea0-0121-2897-b121-36638c259554" xmlns="urn:xmpp:fasten:0">
+                        <retract xmlns="urn:xmpp:message-retract:0"/>
+                    </apply-to>
+                </message>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza));
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
+
+            expect(view.model.messages.length).toBe(1);
+
+            const message = view.model.messages.at(0);
+            expect(message.get('retracted')).toBeTruthy();
+            expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message');
+            expect(msg_el.textContent.trim()).toBe('Mercutio has removed this message');
+        })
+    );
+});

+ 129 - 74
src/plugins/chatview/tests/retractions.js

@@ -1,60 +1,67 @@
 /*global mock, converse */
 
-const { Strophe, u, stx } = converse.env;
-
-describe("A sent chat message", function () {
+const { Strophe, u, stx, dayjs } = converse.env;
 
+describe('A sent chat message', function () {
     beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
 
-    it("can be retracted", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
-        await mock.waitForRoster(_converse, 'current', 1);
-        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        const view = await mock.openChatBoxFor(_converse, contact_jid);
-
-        view.model.sendMessage({ body : 'hello world'});
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
-
-        const message = view.model.messages.at(0);
-        expect(view.model.messages.length).toBe(1);
-        expect(message.get('retracted')).toBeFalsy();
-        expect(message.get('editable')).toBeTruthy();
-
-        const retract_button = await u.waitUntil(() => view.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.api.connection.get().sent_stanzas;
-        await u.waitUntil(() => view.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(retraction_stanza).toEqualStanza(
-            stx`<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>`);
-
-        expect(view.model.messages.length).toBe(1);
-        expect(message.get('retracted')).toBeTruthy();
-        expect(message.get('editable')).toBeFalsy();
-        expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
-        const el = view.querySelector('.chat-msg--retracted .chat-msg__message');
-        expect(el.textContent.trim()).toBe('You have removed this message');
-    }));
+    it(
+        'can be retracted',
+        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+            await mock.waitForRoster(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit';
+            const view = await mock.openChatBoxFor(_converse, contact_jid);
+
+            view.model.sendMessage({ body: 'hello world' });
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+
+            const message = view.model.messages.at(0);
+            expect(view.model.messages.length).toBe(1);
+            expect(message.get('retracted')).toBeFalsy();
+            expect(message.get('editable')).toBeTruthy();
+
+            const retract_button = await u.waitUntil(() =>
+                view.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.api.connection.get().sent_stanzas;
+            await u.waitUntil(() => view.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 retract')).pop()
+            );
+            expect(retraction_stanza).toEqualStanza(stx`
+                <message id="${retraction_stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">
+                    <retract id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:message-retract:1" />
+                    <body>/me retracted a message</body>
+                    <store xmlns="urn:xmpp:hints"/>
+                    <fallback xmlns="urn:xmpp:fallback:0" for="urn:xmpp:message-retract:1" />
+                </message>`);
+
+            expect(view.model.messages.length).toBe(1);
+            expect(message.get('retracted')).toBeTruthy();
+            expect(message.get('editable')).toBeFalsy();
+            expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const el = view.querySelector('.chat-msg--retracted .chat-msg__message');
+            expect(el.textContent.trim()).toBe('You have removed this message');
+        })
+    );
 });
 
-describe("A received chat message", function () {
-
-    it("can be followed up with a retraction", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
-        await mock.waitForRoster(_converse, 'current', 1);
-        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        const view = await mock.openChatBoxFor(_converse, contact_jid);
+describe('A received chat message', function () {
+    it(
+        'can be followed up with a retraction',
+        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+            await mock.waitForRoster(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit';
+            const view = await mock.openChatBoxFor(_converse, contact_jid);
 
-        const received_stanza = stx`
+            const received_stanza = stx`
             <message xmlns="jabber:client"
                     to="${_converse.bare_jid}"
                     type="chat"
@@ -66,29 +73,77 @@ describe("A received chat message", function () {
                 <stanza-id xmlns="urn:xmpp:sid:0" id="kxViLhgbnNMcWv10" by="${_converse.bare_jid}"/>
             </message>`;
 
-        _converse.api.connection.get()._dataRecv(mock.createRequest(received_stanza));
-        await u.waitUntil(() => view.model.messages.length === 1);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
-
-        const retraction_stanza = stx`
-            <message id="${u.getUniqueId()}"
-                     to="${_converse.bare_jid}"
-                     from="${contact_jid}"
-                     type="chat"
-                     xmlns="jabber:client">
-                <apply-to id="29132ea0-0121-2897-b121-36638c259554" xmlns="urn:xmpp:fasten:0">
-                    <retract xmlns="urn:xmpp:message-retract:0"/>
-                </apply-to>
-            </message>`;
-        _converse.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza));
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
-
-        expect(view.model.messages.length).toBe(1);
-
-        const message = view.model.messages.at(0);
-        expect(message.get('retracted')).toBeTruthy();
-        expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
-        const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message');
-        expect(msg_el.textContent.trim()).toBe('Mercutio has removed this message');
-    }));
+            _converse.api.connection.get()._dataRecv(mock.createRequest(received_stanza));
+            await u.waitUntil(() => view.model.messages.length === 1);
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+
+            const retraction_stanza = stx`
+                <message id="${u.getUniqueId()}"
+                        to="${_converse.bare_jid}"
+                        from="${contact_jid}"
+                        type="chat"
+                        xmlns="jabber:client">
+                    <retract id="29132ea0-0121-2897-b121-36638c259554" xmlns="urn:xmpp:message-retract:1"/>
+                    <fallback xmlns="urn:xmpp:fallback:0" for="urn:xmpp:message-retract:1" />
+                    <body>/me retracted a message</body>
+                    <store xmlns="urn:xmpp:hints"/>
+                </message>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza));
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
+
+            expect(view.model.messages.length).toBe(1);
+
+            const message = view.model.messages.at(0);
+            expect(message.get('retracted')).toBeTruthy();
+            expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message');
+            expect(msg_el.textContent.trim()).toBe('Mercutio has removed this message');
+        })
+    );
+
+    it(
+        'may be preceded with a retraction',
+        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+            await mock.waitForRoster(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit';
+            const view = await mock.openChatBoxFor(_converse, contact_jid);
+
+            const retraction_stanza = stx`
+                <message id="${u.getUniqueId()}"
+                         to="${_converse.bare_jid}"
+                         from="${contact_jid}"
+                         type="chat"
+                         xmlns="jabber:client">
+                    <retract id="29132ea0-0121-2897-b121-36638c259554" xmlns="urn:xmpp:message-retract:1"/>
+                    <fallback xmlns="urn:xmpp:fallback:0" for="urn:xmpp:message-retract:1" />
+                    <body>/me retracted a message</body>
+                    <store xmlns="urn:xmpp:hints"/>
+                </message>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza));
+            await u.waitUntil(() => view.model.messages.length === 1);
+
+            const hour_ago = dayjs().subtract(1, 'hour');
+
+            const message_stanza = stx`
+                <message xmlns="jabber:client"
+                        to="${_converse.bare_jid}"
+                        type="chat"
+                        id="29132ea0-0121-2897-b121-36638c259554"
+                        from="${contact_jid}">
+                    <body>This message will be retracted</body>
+                    <delay xmlns="urn:xmpp:delay" stamp="${hour_ago.toISOString()}"/>
+                    <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.api.connection.get()._dataRecv(mock.createRequest(message_stanza));
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
+
+            expect(view.model.messages.length).toBe(1);
+            const message = view.model.messages.at(0);
+            expect(message.get('retracted')).toBeTruthy();
+            expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message');
+            expect(msg_el.textContent.trim()).toBe('Mercutio has removed this message');
+        })
+    );
 });

+ 795 - 0
src/plugins/muc-views/tests/deprecated-retractions.js

@@ -0,0 +1,795 @@
+/*global mock, converse */
+const { Strophe, u, stx } = converse.env;
+
+describe("Deprecated Message Retractions", function () {
+    describe("A groupchat message retraction", function () {
+
+        it("is not applied if it's not from the right author",
+                mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+            const received_stanza = stx`
+                <message to="${_converse.jid}"
+                        from="${muc_jid}/eve"
+                        type="groupchat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
+                    <body>Hello world</body>
+                    <stanza-id xmlns="urn:xmpp:sid:0" id="stanza-id-1" by="${muc_jid}"/>
+                </message>
+            `;
+            const view = _converse.chatboxviews.get(muc_jid);
+            await view.model.handleMessageStanza(received_stanza);
+            await u.waitUntil(() => view.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 = stx`
+                <message type="groupchat"
+                        id='retraction-id-1'
+                        from="${muc_jid}/mallory"
+                        to="${muc_jid}/romeo"
+                        xmlns="jabber:client">
+                    <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.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza));
+            await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
+            expect(await view.model.handleRetraction.calls.first().returnValue).toBe(true);
+            expect(view.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();
+        }));
+
+        it("can be received before the message it pertains to",
+                mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+            const date = (new Date()).toISOString();
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+            const retraction_stanza = stx`
+                <message type="groupchat"
+                        id="retraction-id-1"
+                        from="${muc_jid}/eve"
+                        to="${muc_jid}/romeo"
+                        xmlns="jabber:client">
+                    <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.chatboxviews.get(muc_jid);
+            spyOn(converse.env.log, 'warn');
+            spyOn(view.model, 'handleRetraction').and.callThrough();
+            _converse.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza));
+
+            await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
+            await u.waitUntil(() => view.model.messages.length === 1);
+            expect(await 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 = stx`
+                <message to="${_converse.jid}"
+                        from="${muc_jid}/eve"
+                        type="groupchat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
+                    <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.api.connection.get()._dataRecv(mock.createRequest(received_stanza));
+            await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1, 1000);
+            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(await view.model.handleRetraction.calls.all().pop().returnValue).toBe(true);
+        }));
+    });
+
+    describe("A groupchat message moderator retraction", function () {
+
+        it("can be received before the message it pertains to",
+                mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+            const date = (new Date()).toISOString();
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+            const retraction_stanza = stx`
+                <message xmlns="jabber:client" from="${muc_jid}" type="groupchat" id="retraction-id-1">
+                    <apply-to xmlns="urn:xmpp:fasten:0" id="stanza-id-1">
+                        <moderated xmlns="urn:xmpp:message-moderate:0" by="${muc_jid}/madison">
+                            <retract xmlns="urn:xmpp:message-retract:0"/>
+                            <reason>Insults</reason>
+                        </moderated>
+                    </apply-to>
+                </message>
+            `;
+            const view = _converse.chatboxviews.get(muc_jid);
+            spyOn(converse.env.log, 'warn');
+            spyOn(view.model, 'handleModeration').and.callThrough();
+            _converse.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza));
+
+            await u.waitUntil(() => view.model.handleModeration.calls.count() === 1);
+            await u.waitUntil(() => view.model.messages.length === 1);
+            expect(await view.model.handleModeration.calls.first().returnValue).toBe(true);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+            expect(view.model.messages.at(0).get('dangling_moderation')).toBe(true);
+
+            const received_stanza = stx`
+                <message to="${_converse.jid}"
+                        from="${muc_jid}/eve"
+                        type="groupchat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
+                    <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}"/>
+                </message>`;
+
+            _converse.api.connection.get()._dataRecv(mock.createRequest(received_stanza));
+            await u.waitUntil(() => view.model.handleModeration.calls.count() === 2);
+
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+            expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.model.messages.length).toBe(1);
+
+            const message = view.model.messages.at(0)
+            expect(message.get('moderated')).toBe('retracted');
+            expect(message.get('dangling_moderation')).toBe(false);
+            expect(message.get(`stanza_id ${muc_jid}`)).toBe('stanza-id-1');
+            expect(message.get('time')).toBe(date);
+            expect(message.get('type')).toBe('groupchat');
+            expect(await view.model.handleModeration.calls.all().pop().returnValue).toBe(true);
+        }));
+    });
+
+    describe("A message retraction", function () {
+
+        it("can be received before the message it pertains to",
+                mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+            const date = (new Date()).toISOString();
+            await mock.waitForRoster(_converse, 'current', 1);
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const view = await mock.openChatBoxFor(_converse, contact_jid);
+            spyOn(view.model, 'handleRetraction').and.callThrough();
+
+            const retraction_stanza =  stx`
+                <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.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza));
+            await u.waitUntil(() => view.model.messages.length === 1);
+            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.querySelectorAll('.chat-msg').length).toBe(0);
+
+            const stanza = stx`
+                <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.api.connection.get()._dataRecv(mock.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');
+        }));
+    });
+
+    describe("A Received Chat Message", function () {
+
+        it("can be followed up by a retraction", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+            await mock.waitForRoster(_converse, 'current', 1);
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const view = await mock.openChatBoxFor(_converse, contact_jid);
+
+            let stanza = stx`
+                <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.api.connection.get()._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => view.model.messages.length === 1);
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+
+            stanza = stx`
+                <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.api.connection.get()._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => view.model.messages.length === 2);
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+
+            const retraction_stanza =  stx`
+                <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.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza));
+            await u.waitUntil(() => view.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.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message');
+            expect(msg_el.textContent.trim()).toBe('Mercutio has removed this message');
+            expect(u.hasClass('chat-msg--followup', view.querySelector('.chat-msg--retracted'))).toBe(true);
+        }));
+    });
+
+    describe("A Received Groupchat Message", function () {
+
+        it("can be followed up by a retraction by the author", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+            const received_stanza = stx`
+            <message to="${_converse.jid}"
+                        from="${muc_jid}/eve"
+                        type="groupchat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
+                    <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.chatboxviews.get(muc_jid);
+            await view.model.handleMessageStanza(received_stanza);
+            await u.waitUntil(() => view.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 = stx`
+                <message type="groupchat"
+                        id="retraction-id-1"
+                        from="${muc_jid}/eve"
+                        to="${muc_jid}/romeo"
+                        xmlns="jabber:client">
+                    <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.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza));
+
+            // We opportunistically save the message as retracted, even before receiving the retraction message
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('retracted')).toBeTruthy();
+            expect(view.model.messages.at(0).get('editable')).toBe(false);
+            expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message');
+            expect(msg_el.textContent.trim()).toBe('eve has removed this message');
+            expect(msg_el.querySelector('.chat-msg--retracted q')).toBe(null);
+        }));
+
+
+        it("can be retracted by a moderator, with the IQ response received before the retraction message",
+                mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+            const view = _converse.chatboxviews.get(muc_jid);
+            const occupant = view.model.getOwnOccupant();
+            expect(occupant.get('role')).toBe('moderator');
+
+            const received_stanza = stx`
+                <message to="${_converse.jid}"
+                        from="${muc_jid}/mallory"
+                        type="groupchat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
+                    <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.handleMessageStanza(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.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.api.connection.get().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 = stx`
+                <iq from="${muc_jid}"
+                    id="${stanza.getAttribute('id')}"
+                    to="${_converse.bare_jid}"
+                    type="result"
+                    xmlns="jabber:client"/>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(result_iq));
+
+            // We opportunistically save the message as retracted, even before receiving the retraction message
+            await u.waitUntil(() => view.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.model.messages.at(0).get('editable')).toBe(false);
+            expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+
+            const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message');
+            expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo has removed this message');
+
+            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 = stx`
+                <message type="groupchat"
+                        id="retraction-id-1"
+                        from="${muc_jid}"
+                        to="${muc_jid}/romeo"
+                        xmlns="jabber:client">
+                    <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.handleMessageStanza(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);
+            expect(view.model.messages.at(0).get('editable')).toBe(false);
+        }));
+
+        it("can not be retracted if the MUC doesn't support message moderation",
+                mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+            const occupant = view.model.getOwnOccupant();
+            expect(occupant.get('role')).toBe('moderator');
+
+            const received_stanza = stx`
+                <message to="${_converse.jid}"
+                        from="${muc_jid}/mallory"
+                        type="groupchat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
+                    <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.handleMessageStanza(received_stanza);
+            await u.waitUntil(() => view.querySelector('.chat-msg__content'));
+            expect(view.querySelector('.chat-msg__content .chat-msg__action-retract')).toBe(null);
+            const result = await view.model.canModerateMessages();
+            expect(result).toBe(false);
+        }));
+
+
+        it("can be retracted by a moderator, with the retraction message received before the IQ response",
+                mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+            const view = _converse.chatboxviews.get(muc_jid);
+            const occupant = view.model.getOwnOccupant();
+            expect(occupant.get('role')).toBe('moderator');
+
+            const received_stanza = stx`
+                <message to="${_converse.jid}"
+                        from="${muc_jid}/mallory"
+                        type="groupchat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
+                    <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.handleMessageStanza(received_stanza);
+            await u.waitUntil(() => view.model.messages.length === 1);
+            expect(view.model.messages.length).toBe(1);
+
+            const retract_button = await u.waitUntil(() => view.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.api.connection.get().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 = stx`
+                <message type="groupchat"
+                        id='retraction-id-1'
+                        from="${muc_jid}"
+                        to="${muc_jid}/romeo"
+                        xmlns="jabber:client">
+                    <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.handleMessageStanza(retraction);
+
+            await u.waitUntil(() => view.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.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message div');
+            expect(msg_el.textContent).toBe('romeo has removed this message');
+            const qel = view.querySelector('.chat-msg--retracted .chat-msg__message q');
+            expect(qel.textContent).toBe('This content is inappropriate for this forum!');
+
+            const result_iq = stx`
+                <iq from="${muc_jid}"
+                    id="${stanza.getAttribute('id')}"
+                    to="${_converse.bare_jid}"
+                    type="result"
+                    xmlns="jabber:client"/>`;
+            _converse.api.connection.get()._dataRecv(mock.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);
+            expect(view.model.messages.at(0).get('editable')).toBe(false);
+        }));
+    });
+
+
+    describe("when archived", function () {
+
+        it("may be returned as a tombstone message",
+            mock.initConverse(
+                ['discoInitialized'], {},
+                async function (_converse) {
+
+            await mock.waitForRoster(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+            const sent_IQs = _converse.api.connection.get().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 = stx`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" xmlns="jabber:client">
+                    <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.api.connection.get()._dataRecv(mock.createRequest(first_message));
+
+            const tombstone = stx`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" xmlns="jabber:client">
+                    <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.api.connection.get()._dataRecv(mock.createRequest(tombstone));
+
+            const last_id = u.getUniqueId();
+            const retraction = stx`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" xmlns="jabber:client">
+                    <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.api.connection.get()._dataRecv(mock.createRequest(retraction));
+
+            const iq_result = stx`
+                <iq type="result" id="${stanza.getAttribute('id')}" xmlns="jabber:client">
+                    <fin xmlns="urn:xmpp:mam:2">
+                        <set xmlns="http://jabber.org/protocol/rsm">
+                            <first index="0">${first_id}</first>
+                            <last>${last_id}</last>
+                            <count>2</count>
+                        </set>
+                    </fin>
+                </iq>`;
+            _converse.api.connection.get()._dataRecv(mock.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(await view.model.handleRetraction.calls.first().returnValue).toBe(false);
+            expect(await view.model.handleRetraction.calls.all()[1].returnValue).toBe(false);
+            expect(await view.model.handleRetraction.calls.all()[2].returnValue).toBe(true);
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+            expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const el = view.querySelector('.chat-msg--retracted .chat-msg__message div');
+            expect(el.textContent.trim()).toBe('Mercutio has removed this message');
+            expect(u.hasClass('chat-msg--followup', el.parentElement)).toBe(false);
+        }));
+
+        it("may be returned as a tombstone groupchat message",
+            mock.initConverse(
+                ['discoInitialized'], {},
+                async function (_converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+            const view = _converse.chatboxviews.get(muc_jid);
+
+            const sent_IQs = _converse.api.connection.get().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 = stx`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}" xmlns="jabber:client">
+                    <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();
+            _converse.api.connection.get()._dataRecv(mock.createRequest(tombstone));
+
+            const last_id = u.getUniqueId();
+            const retraction = stx`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}" xmlns="jabber:client">
+                    <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.api.connection.get()._dataRecv(mock.createRequest(retraction));
+
+            const iq_result = stx`
+                <iq type="result" id="${stanza.getAttribute('id')}" xmlns="jabber:client">
+                    <fin xmlns="urn:xmpp:mam:2">
+                        <set xmlns="http://jabber.org/protocol/rsm">
+                            <first index="0">${first_id}</first>
+                            <last>${last_id}</last>
+                            <count>2</count>
+                        </set>
+                    </fin>
+                </iq>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(iq_result));
+
+            await u.waitUntil(() => view.model.messages.length === 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(await view.model.handleRetraction.calls.first().returnValue).toBe(false);
+            expect(await 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);
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+            expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const el = view.querySelector('.chat-msg--retracted .chat-msg__message div');
+            expect(el.textContent.trim()).toBe('eve has removed this message');
+        }));
+
+        it("may be returned as a tombstone moderated groupchat message",
+            mock.initConverse(
+                ['discoInitialized', 'chatBoxesFetched'], {},
+                async function (_converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+            const view = _converse.chatboxviews.get(muc_jid);
+
+            const sent_IQs = _converse.api.connection.get().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 = stx`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}" xmlns="jabber:client">
+                    <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();
+            _converse.api.connection.get()._dataRecv(mock.createRequest(tombstone));
+
+            const last_id = u.getUniqueId();
+            const retraction = stx`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}" xmlns="jabber:client">
+                    <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.api.connection.get()._dataRecv(mock.createRequest(retraction));
+
+            const iq_result = stx`
+                <iq type="result" id="${stanza.getAttribute('id')}" xmlns="jabber:client">
+                    <fin xmlns="urn:xmpp:mam:2">
+                        <set xmlns="http://jabber.org/protocol/rsm">
+                            <first index="0">${first_id}</first>
+                            <last>${last_id}</last>
+                            <count>2</count>
+                        </set>
+                    </fin>
+                </iq>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(iq_result));
+
+            await u.waitUntil(() => view.model.messages.length);
+            expect(view.model.messages.length).toBe(1);
+            let message = view.model.messages.at(0);
+            await u.waitUntil(() => message.get('retracted'));
+            expect(message.get('is_tombstone')).toBe(true);
+
+            await u.waitUntil(() => view.model.handleModeration.calls.count() === 2);
+            expect(await view.model.handleModeration.calls.first().returnValue).toBe(false);
+            expect(await 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");
+
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg').length, 500);
+            expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+
+            expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const el = view.querySelector('.chat-msg--retracted .chat-msg__message div');
+            expect(el.textContent.trim()).toBe('A moderator has removed this message');
+            const qel = view.querySelector('.chat-msg--retracted .chat-msg__message q');
+            expect(qel.textContent.trim()).toBe('This message contains inappropriate content');
+        }));
+    });
+});

+ 23 - 20
src/plugins/muc-views/tests/retractions.js

@@ -31,6 +31,8 @@ async function sendAndThenRetractMessage (_converse, view) {
 
 
 describe("Message Retractions", function () {
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
     describe("A groupchat message retraction", function () {
 
         it("is not applied if it's not from the right author",
@@ -346,21 +348,22 @@ describe("Message Retractions", function () {
             await u.waitUntil(() => view.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 retraction_stanza = await u.waitUntil(
+                () => sent_stanzas.filter(s => s.querySelector('message retract')).pop());
+            expect(retraction_stanza).toEqualStanza(stx`
+                <message id="${retraction_stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">
+                    <retract id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:message-retract:1"/>
+                    <body>/me retracted a message</body>
+                    <store xmlns="urn:xmpp:hints"/>
+                    <fallback xmlns="urn:xmpp:fallback:0" for="urn:xmpp:message-retract:1" />
+                </message>`);
 
             expect(view.model.messages.length).toBe(1);
             expect(message.get('retracted')).toBeTruthy();
             expect(message.get('editable')).toBeFalsy();
             expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
             const el = view.querySelector('.chat-msg--retracted .chat-msg__message');
-            expect(el.textContent.trim()).toBe('Romeo Montague has removed this message');
+            expect(el.textContent.trim()).toBe('You have removed this message');
         }));
     });
 
@@ -627,13 +630,13 @@ describe("Message Retractions", function () {
             const msg_obj = view.model.messages.last();
             expect(msg_obj.get('retracted')).toBeTruthy();
 
-            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>`);
+            expect(retraction_stanza).toEqualStanza(stx`
+                <message id="${retraction_stanza.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">
+                    <retract id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:message-retract:0"/>
+                    <store xmlns="urn:xmpp:hints"/>
+                    <apply-to  xmlns="urn:xmpp:fasten:0">
+                    </apply-to>
+                </message>`);
 
             const message = view.model.messages.last();
             expect(message.get('is_ephemeral')).toBe(false);
@@ -662,7 +665,7 @@ describe("Message Retractions", function () {
             expect(view.model.messages.last().get('editable')).toBe(false);
             expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
             const el = view.querySelector('.chat-msg--retracted .chat-msg__message div');
-            expect(el.textContent).toBe('romeo has removed this message');
+            expect(el.textContent).toBe('You have removed this message');
         }));
 
         it("can be retracted by its author, causing an error message in response",
@@ -683,7 +686,7 @@ describe("Message Retractions", function () {
             expect(view.model.messages.length).toBe(1);
             await u.waitUntil(() => view.model.messages.last().get('retracted'), 1000);
             const el = view.querySelector('.chat-msg--retracted .chat-msg__message div');
-            expect(el.textContent.trim()).toBe('romeo has removed this message');
+            expect(el.textContent.trim()).toBe('You have removed this message');
 
             const message = view.model.messages.last();
             const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
@@ -731,7 +734,7 @@ describe("Message Retractions", function () {
             expect(view.model.messages.last().get('retracted')).toBeTruthy();
             await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
             const el = view.querySelector('.chat-msg--retracted .chat-msg__message div');
-            expect(el.textContent.trim()).toBe('romeo has removed this message');
+            expect(el.textContent.trim()).toBe('You have removed this message');
 
             await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
 
@@ -938,7 +941,7 @@ describe("Message Retractions", function () {
                             <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"/>
+                                <retracted id="retract-message-1" stamp="2019-09-20T23:09:32Z" xmlns="urn:xmpp:message-retract:1"/>
                             </message>
                         </forwarded>
                     </result>

+ 4 - 0
src/shared/styles/messages.scss

@@ -288,6 +288,10 @@
                 width: calc(100% - var(--message-avatar-width));
             }
 
+            .chat-msg__content--action {
+                width: 100%;
+            }
+
             &.chat-msg--followup {
                 .chat-msg__heading,
                 .show-msg-author-modal {