diff --git a/src/headless/plugins/muc/muc.js b/src/headless/plugins/muc/muc.js
index 0e87631ede..4472da3507 100644
--- a/src/headless/plugins/muc/muc.js
+++ b/src/headless/plugins/muc/muc.js
@@ -2078,7 +2078,14 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) {
* whether a message was moderated or not.
*/
async handleModeration (attrs) {
- const MODERATION_ATTRIBUTES = ['editable', 'moderated', 'moderated_by', 'moderated_id', 'moderation_reason'];
+ const MODERATION_ATTRIBUTES = [
+ 'editable',
+ 'moderated',
+ 'moderated_by',
+ 'moderated_by_id',
+ 'moderated_id',
+ 'moderation_reason'
+ ];
if (attrs.moderated === 'retracted') {
const query = {};
const key = `stanza_id ${this.get('jid')}`;
@@ -2096,7 +2103,7 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) {
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);
+ 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;
diff --git a/src/headless/plugins/muc/occupants.js b/src/headless/plugins/muc/occupants.js
index cd26f54f55..c3c868d832 100644
--- a/src/headless/plugins/muc/occupants.js
+++ b/src/headless/plugins/muc/occupants.js
@@ -143,7 +143,7 @@ class MUCOccupants extends Collection {
* Lookup by occupant_id is done first, then jid, and then nick.
*
* @method _converse.MUCOccupants#findOccupant
- * @param { OccupantData } data
+ * @param {OccupantData} data
*/
findOccupant (data) {
if (data.occupant_id) {
diff --git a/src/headless/plugins/muc/parsers.js b/src/headless/plugins/muc/parsers.js
index 3c243f587d..1fdd029e93 100644
--- a/src/headless/plugins/muc/parsers.js
+++ b/src/headless/plugins/muc/parsers.js
@@ -130,6 +130,7 @@ function getModerationAttributes(stanza) {
editable: false,
moderated: 'retracted',
moderated_by: moderated.getAttribute('by'),
+ moderated_by_id: moderated.querySelector('occupant-id')?.getAttribute('id'),
moderated_id: retract.getAttribute('id'),
moderation_reason: retract.querySelector('reason')?.textContent,
};
@@ -141,6 +142,7 @@ function getModerationAttributes(stanza) {
editable: false,
is_tombstone: true,
moderated_by: tombstone.getAttribute('by'),
+ moderated_by_id: tombstone.querySelector('occupant-id')?.getAttribute('id'),
retracted: tombstone.getAttribute('stamp'),
moderation_reason: tombstone.querySelector('reason')?.textContent,
};
diff --git a/src/headless/plugins/muc/tests/affiliations.js b/src/headless/plugins/muc/tests/affiliations.js
index 6dc84c146c..d905ce30ff 100644
--- a/src/headless/plugins/muc/tests/affiliations.js
+++ b/src/headless/plugins/muc/tests/affiliations.js
@@ -1,41 +1,37 @@
/*global mock, converse */
-
-const $pres = converse.env.$pres;
-const Strophe = converse.env.Strophe;
+const { stx, Strophe } = converse.env;
describe('The MUC Affiliations API', function () {
+ beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
it('can be used to set affiliations in MUCs without having to join them first',
mock.initConverse([], {}, async function (_converse) {
const { api } = _converse;
const user_jid = 'annoyingguy@montague.lit';
const muc_jid = 'lounge@montague.lit';
await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
- const presence = $pres({
- 'from': 'lounge@montague.lit/annoyingGuy',
- 'id': '27C55F89-1C6A-459A-9EB5-77690145D624',
- 'to': 'romeo@montague.lit/desktop'
- })
- .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user' })
- .c('item', {
- 'jid': user_jid,
- 'affiliation': 'member',
- 'role': 'participant'
- });
+ const presence = stx`
+
+
+
+ `;
_converse.api.connection.get()._dataRecv(mock.createRequest(presence));
- api.rooms.affiliations.set(muc_jid, { 'jid': user_jid, 'affiliation': 'outcast', 'reason': 'Ban hammer!' });
+ api.rooms.affiliations.set(muc_jid, { jid: user_jid, affiliation: 'outcast', reason: 'Ban hammer!' });
const iq = _converse.api.connection.get().IQ_stanzas.pop();
- expect(Strophe.serialize(iq)).toBe(
- `` +
- `` +
- `- ` +
- `Ban hammer!` +
- `
` +
- `` +
- ``);
-
+ expect(iq).toEqualStanza(stx`
+
+
+ -
+ Ban hammer!
+
+
+ `);
})
);
});
diff --git a/src/headless/types/plugins/muc/occupants.d.ts b/src/headless/types/plugins/muc/occupants.d.ts
index 88001a49ba..71a847a1ac 100644
--- a/src/headless/types/plugins/muc/occupants.d.ts
+++ b/src/headless/types/plugins/muc/occupants.d.ts
@@ -32,7 +32,7 @@ declare class MUCOccupants extends Collection {
* Lookup by occupant_id is done first, then jid, and then nick.
*
* @method _converse.MUCOccupants#findOccupant
- * @param { OccupantData } data
+ * @param {OccupantData} data
*/
findOccupant(data: {
jid?: string;
diff --git a/src/plugins/muc-views/tests/retractions.js b/src/plugins/muc-views/tests/retractions.js
index cdfc2164a1..6fb0aad8b8 100644
--- a/src/plugins/muc-views/tests/retractions.js
+++ b/src/plugins/muc-views/tests/retractions.js
@@ -365,7 +365,6 @@ describe("Message Retractions", function () {
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) {
@@ -439,6 +438,85 @@ describe("Message Retractions", function () {
expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
expect(view.model.messages.at(0).get('editable')).toBe(false);
}));
+
+ it("can be followed up by a retraction from a different moderator",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const nick = 'romeo';
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE, Strophe.NS.OCCUPANTID];
+ const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features);
+
+ // The other moderator enters
+ const name = mock.chatroom_names[0];
+ const user_jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const mod_jid = `${muc_jid}/${name}`;
+ const mod_occ_id = u.getUniqueId();
+ _converse.api.connection.get()._dataRecv(mock.createRequest(
+ stx`
+
+
+
+
+ `));
+
+ await u.waitUntil(() => model.occupants.length === 2);
+ const mod = model.occupants.findOccupant({ occupant_id: mod_occ_id });
+ expect(mod.get('affiliation')).toBe('moderator');
+ expect(mod.get('occupant_id')).toBe(mod_occ_id);
+
+ const stanza_id = 'stanza-id-1';
+ const received_stanza = stx`
+
+ Visit this site to get free Bitcoin!
+
+ `;
+ await model.handleMessageStanza(received_stanza);
+ await u.waitUntil(() => model.messages.length === 1);
+ expect(model.messages.length).toBe(1);
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ const reason = "This content is inappropriate for this forum!"
+
+ const retraction = stx`
+
+
+
+
+
+ ${reason}
+
+ `;
+ 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 .retraction');
+ expect(msg_el.firstElementChild.textContent.trim()).toBe('Dyon van de Wege has removed a message');
+ const qel = view.querySelector('.chat-msg--retracted .chat-msg__message q');
+ expect(qel.textContent).toBe('This content is inappropriate for this forum!');
+
+ expect(view.model.messages.length).toBe(1);
+ const message = view.model.messages.at(0);
+ expect(message.get('moderated')).toBe('retracted');
+ expect(message.get('moderated_by')).toBe(mod_jid);
+ expect(message.get('moderated_by_id')).toBe(mod_occ_id);
+ expect(message.get('moderation_reason')).toBe(reason);
+ expect(message.get('editable')).toBe(false);
+ }));
});