diff --git a/.aiderignore b/.aiderignore index 952bcbbb6c..e8e3c9596f 100644 --- a/.aiderignore +++ b/.aiderignore @@ -1,6 +1,21 @@ # Add files and directories to ignore by Aider - node_modules/ - dist/ - build/ *.log *.tmp + 3rdparty/libsignal-protocol.min.js + LICENSE + build/ + certs/ + demo/ + develop-eggs/ + dist/ + docs/doctrees + docs/html/ + images/ + logo/ + media/ + node_modules/ + share/ + sounds/ + src/headless/types + src/types/ + tags diff --git a/CHANGES.md b/CHANGES.md index 6d8f5401f5..8bf1425a19 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -49,6 +49,9 @@ - Fix: renaming getEmojisByAtrribute to getEmojisByAttribute. ### Changes and features +- Upgrade to the latest versions of XEP-0424 and XEP-0425 (Message Retraction and Message Moderation). + Converse loses the ability to retract or moderate messages in the older format, + so you might need to upgrade your XMPP server's implementation of these as well. - Embed the Spotify player for links to Spotify tracks. New config option [embed_3rd_party_media_players](https://conversejs.org/docs/html/configuration.html#embed-3rd-party-media-players). - Add support for XEP-0191 Blocking Command - Upgrade to Bootstrap 5 diff --git a/karma.conf.js b/karma.conf.js index ebc74a83e3..32e486f5b0 100644 --- a/karma.conf.js +++ b/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,6 +66,7 @@ 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' }, @@ -81,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' }, @@ -99,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' }, diff --git a/src/headless/plugins/chat/message.js b/src/headless/plugins/chat/message.js index c3949028c7..4f72b16ad8 100644 --- a/src/headless/plugins/chat/message.js +++ b/src/headless/plugins/chat/message.js @@ -140,6 +140,13 @@ class Message extends ModelWithContact(ColorAwareModel(Model)) { return text.startsWith('/me '); } + /** + * @returns {boolean} + */ + isRetracted () { + return this.get('retracted') || this.get('moderated') === 'retracted'; + } + /** * Returns a boolean indicating whether this message is considered a followup * message from the previous one. Followup messages are shown grouped together @@ -161,6 +168,7 @@ class Message extends ModelWithContact(ColorAwareModel(Model)) { } const date = dayjs(this.get('time')); return this.get('from') === prev_model.get('from') && + !this.isRetracted() && !prev_model.isRetracted() && !this.isMeCommand() && !prev_model.isMeCommand() && !!this.get('is_encrypted') === !!prev_model.get('is_encrypted') && this.get('type') === prev_model.get('type') && this.get('type') !== 'info' && diff --git a/src/headless/plugins/chat/parsers.js b/src/headless/plugins/chat/parsers.js index 06a1e32bbc..b191cb90f4 100644 --- a/src/headless/plugins/chat/parsers.js +++ b/src/headless/plugins/chat/parsers.js @@ -121,7 +121,6 @@ export async function parseMessage (stanza) { 'is_marker': !!marker, 'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length, 'marker_id': marker && marker.getAttribute('id'), - 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), 'nick': contact?.attributes?.nickname, 'receipt_id': getReceiptId(stanza), 'received': new Date().toISOString(), diff --git a/src/headless/plugins/disco/api.js b/src/headless/plugins/disco/api.js index 0dd870cb7c..01af9b7282 100644 --- a/src/headless/plugins/disco/api.js +++ b/src/headless/plugins/disco/api.js @@ -359,7 +359,6 @@ export default { return api.disco.features.has(feature, jid); } catch (e) { log.error(e); - debugger; return false; } }, diff --git a/src/headless/plugins/muc/message.js b/src/headless/plugins/muc/message.js index 5ea9642192..9db1bfe607 100644 --- a/src/headless/plugins/muc/message.js +++ b/src/headless/plugins/muc/message.js @@ -41,11 +41,10 @@ class MUCMessage extends Message { /** * Determines whether this messsage may be moderated, * based on configuration settings and server support. - * @async * @method _converse.ChatRoomMessages#mayBeModerated - * @returns {boolean} + * @returns {Promise} */ - mayBeModerated () { + async mayBeModerated () { if (typeof this.get('from_muc') === 'undefined') { // If from_muc is not defined, then this message hasn't been // reflected yet, which means we won't have a XEP-0359 stanza id. @@ -53,8 +52,7 @@ class MUCMessage extends Message { } return ( ['all', 'moderator'].includes(api.settings.get('allow_message_retraction')) && - this.get(`stanza_id ${this.get('from_muc')}`) && - this.chatbox.canModerateMessages() + this.get(`stanza_id ${this.get('from_muc')}`) && await this.chatbox.canModerateMessages() ); } diff --git a/src/headless/plugins/muc/muc.js b/src/headless/plugins/muc/muc.js index f22ac17243..4472da3507 100644 --- a/src/headless/plugins/muc/muc.js +++ b/src/headless/plugins/muc/muc.js @@ -808,30 +808,27 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) { */ async retractOwnMessage(message) { const __ = _converse.__; - const origin_id = message.get('origin_id'); - if (!origin_id) { - throw new Error("Can't retract message without a XEP-0359 Origin ID"); - } const editable = message.get('editable'); - const stanza = $msg({ - 'id': 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 }); + const retraction_id = getUniqueId(); + const id = message.get('id'); + + const stanza = stx` + + + /me retracted a message + + + `; // Optimistic save message.set({ - 'retracted': new Date().toISOString(), - 'retracted_id': origin_id, - 'retraction_id': stanza.tree().getAttribute('id'), - 'editable': false + retracted: new Date().toISOString(), + retracted_id: id, + retraction_id: retraction_id, + editable: false }); const result = await this.sendTimedMessage(stanza); @@ -841,11 +838,11 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) { log.error(result); message.save({ editable, - 'error_type': 'timeout', - 'error': __('A timeout happened while while trying to retract your message.'), - 'retracted': undefined, - 'retracted_id': undefined, - 'retraction_id': undefined + error_type: 'timeout', + error: __('A timeout happened while trying to retract your message.'), + retracted: undefined, + retracted_id: undefined, + retraction_id: undefined }); } } @@ -890,16 +887,13 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) { * @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 || ''); + const iq = stx` + + + + ${reason ? stx`${reason}` : ''} + + `; return api.sendIQ(iq, null, false); } @@ -2084,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')}`; @@ -2102,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; @@ -2335,8 +2336,8 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) { this.handleMUCPrivateMessage(attrs) || this.handleMetadataFastening(attrs) || this.handleMEPNotification(attrs) || - (await this.handleRetraction(attrs)) || (await this.handleModeration(attrs)) || + (await this.handleRetraction(attrs)) || (await this.handleSubjectChange(attrs)) ) { attrs.nick && this.removeNotification(attrs.nick, ['composing', 'paused']); 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 bfeb0d428d..1fdd029e93 100644 --- a/src/headless/plugins/muc/parsers.js +++ b/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(); + const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE0}"]`, 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(); + const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE0}"]`, 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,41 @@ 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) { + const retract = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop(); + if (retract) { + const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, retract).pop(); + if (moderated) { + return { + 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, + }; + } + } else { + const tombstone = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop(); + if (tombstone) { + return { + 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, + }; + } + } + return getDeprecatedModerationAttributes(stanza); +} + /** * @param {Element} stanza * @param {'presence'|'message'} type @@ -234,7 +269,6 @@ export async function parseMUCMessage(original_stanza, chatbox) { 'is_marker': !!marker, 'is_unstyled': !!sizzle(`message > unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length, 'marker_id': marker && marker.getAttribute('id'), - 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), 'nick': Strophe.unescapeNode(Strophe.getResourceFromJid(from)), 'occupant_id': getOccupantID(stanza, chatbox), 'receipt_id': getReceiptId(stanza), 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/plugins/status/status.js b/src/headless/plugins/status/status.js index 58789fca14..46e184ad74 100644 --- a/src/headless/plugins/status/status.js +++ b/src/headless/plugins/status/status.js @@ -8,17 +8,16 @@ import { isIdle, getIdleSeconds } from './utils.js'; const { Strophe, $pres } = converse.env; export default class XMPPStatus extends ColorAwareModel(Model) { - - constructor(attributes, options) { + constructor(attributes, options) { super(attributes, options); this.vcard = null; } - defaults () { - return { "status": api.settings.get("default_state") } + defaults() { + return { 'status': api.settings.get('default_state') }; } - getStatus () { + getStatus() { return this.get('status'); } @@ -34,20 +33,20 @@ export default class XMPPStatus extends ColorAwareModel(Model) { return super.get(attr); } - /** - * @param {string|Object} key - * @param {string|Object} [val] - * @param {Object} [options] - */ + /** + * @param {string|Object} key + * @param {string|Object} [val] + * @param {Object} [options] + */ set(key, val, options) { if (key === 'jid' || key === 'nickname') { - throw new Error('Readonly property') + throw new Error('Readonly property'); } return super.set(key, val, options); } - initialize () { - this.on('change', item => { + initialize() { + this.on('change', (item) => { if (!(item.changed instanceof Object)) { return; } @@ -57,15 +56,15 @@ export default class XMPPStatus extends ColorAwareModel(Model) { }); } - getDisplayName () { + getDisplayName() { return this.getFullname() || this.getNickname() || this.get('jid'); } - getNickname () { + getNickname() { return api.settings.get('nickname'); } - getFullname () { + getFullname() { return ''; // Gets overridden in converse-vcard } @@ -74,8 +73,8 @@ export default class XMPPStatus extends ColorAwareModel(Model) { * @param {string} [to] - The JID to which this presence should be sent * @param {string} [status_message] */ - async constructPresence (type, to=null, status_message) { - type = typeof type === 'string' ? type : (this.get('status') || api.settings.get("default_state")); + async constructPresence(type, to = null, status_message) { + type = typeof type === 'string' ? type : this.get('status') || api.settings.get('default_state'); status_message = typeof status_message === 'string' ? status_message : this.get('status_message'); let presence; @@ -84,30 +83,31 @@ export default class XMPPStatus extends ColorAwareModel(Model) { presence = $pres({ to, type }); const { xmppstatus } = _converse.state; const nick = xmppstatus.getNickname(); - if (nick) presence.c('nick', {'xmlns': Strophe.NS.NICK}).t(nick).up(); - - } else if ((type === 'unavailable') || - (type === 'probe') || - (type === 'error') || - (type === 'unsubscribe') || - (type === 'unsubscribed') || - (type === 'subscribed')) { + if (nick) presence.c('nick', { 'xmlns': Strophe.NS.NICK }).t(nick).up(); + } else if ( + type === 'unavailable' || + type === 'probe' || + type === 'error' || + type === 'unsubscribe' || + type === 'unsubscribed' || + type === 'subscribed' + ) { presence = $pres({ to, type }); - } else if (type === 'offline') { presence = $pres({ to, type: 'unavailable' }); - } else if (type === 'online') { presence = $pres({ to }); - } else { presence = $pres({ to }).c('show').t(type).up(); } if (status_message) presence.c('status').t(status_message).up(); - const priority = api.settings.get("priority"); - presence.c('priority').t(Number.isNaN(Number(priority)) ? 0 : priority).up(); + const priority = api.settings.get('priority'); + presence + .c('priority') + .t(Number.isNaN(Number(priority)) ? 0 : priority) + .up(); if (isIdle()) { const idle_since = new Date(); diff --git a/src/headless/shared/actions.js b/src/headless/shared/actions.js index 0629fa5ca5..2978ac370c 100644 --- a/src/headless/shared/actions.js +++ b/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` + + + /me retracted a message + + + `; + return api.connection.get().send(stanza); } diff --git a/src/headless/shared/constants.js b/src/headless/shared/constants.js index f33796949b..ffbd09e937 100644 --- a/src/headless/shared/constants.js +++ b/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'); @@ -84,7 +85,8 @@ Strophe.addNamespace('MAM', 'urn:xmpp:mam:2'); Strophe.addNamespace('MARKERS', 'urn:xmpp:chat-markers:0'); Strophe.addNamespace('MENTIONS', 'urn:xmpp:mmn:0'); Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0'); -Strophe.addNamespace('MODERATE', 'urn:xmpp:message-moderate:0'); +Strophe.addNamespace('MODERATE', 'urn:xmpp:message-moderate:1'); +Strophe.addNamespace('MODERATE0', 'urn:xmpp:message-moderate:0'); Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick'); Strophe.addNamespace('OCCUPANTID', 'urn:xmpp:occupant-id:0'); Strophe.addNamespace('OMEMO', 'eu.siacs.conversations.axolotl'); @@ -94,7 +96,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'); diff --git a/src/headless/shared/model-with-messages.js b/src/headless/shared/model-with-messages.js index 6201f63818..d9652b5459 100644 --- a/src/headless/shared/model-with-messages.js +++ b/src/headless/shared/model-with-messages.js @@ -335,18 +335,19 @@ 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, }); } /** - * @param {File[]} files + * @param {File[]} files' */ async sendFiles(files) { const { __, session } = _converse; @@ -830,7 +831,7 @@ export default function ModelWithMessages(BaseModel) { if (this.get('num_unread') > 0) { this.sendMarkerForMessage(this.messages.last()); } - u.safeSave(this, { 'num_unread': 0 }); + u.safeSave(this, { num_unread: 0 }); } /** @@ -843,23 +844,26 @@ export default function ModelWithMessages(BaseModel) { async handleRetraction(attrs) { const RETRACTION_ATTRIBUTES = ['retracted', 'retracted_id', 'editable']; 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; - await this.createMessage(attrs); - return true; + if (attrs.is_tombstone) return false; + + for (const m of this.messages.models) { + if (m.get('from') !== attrs.from) continue; + if (m.get('origin_id') === attrs.retracted_id || + m.get('msgid') === attrs.retracted_id) { + m.save(pick(attrs, RETRACTION_ATTRIBUTES)); + return true; + } } - message.save(pick(attrs, RETRACTION_ATTRIBUTES)); + + attrs['dangling_retraction'] = true; + await this.createMessage(attrs); 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); + 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; diff --git a/src/headless/shared/parsers.js b/src/headless/shared/parsers.js index f0b7ff9a38..136d74c1ff 100644 --- a/src/headless/shared/parsers.js +++ b/src/headless/shared/parsers.js @@ -96,14 +96,21 @@ export async function parseErrorStanza(stanza) { * @returns {Object} */ export function getStanzaIDs (stanza, original_stanza) { - const attrs = {}; - // Store generic stanza ids + // Generic stanza ids const sids = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza); const sid_attrs = sids.reduce((acc, s) => { acc[`stanza_id ${s.getAttribute('by')}`] = s.getAttribute('id'); return acc; }, {}); - Object.assign(attrs, sid_attrs); + + // Origin id + const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop()?.getAttribute('id'); + + const attrs = { + origin_id, + msgid: stanza.getAttribute('id') || original_stanza.getAttribute('id'), + ...sid_attrs, + }; // Store the archive id const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(); @@ -113,11 +120,6 @@ export function getStanzaIDs (stanza, original_stanza) { attrs[`stanza_id ${by_jid}`] = result.getAttribute('id'); } - // Store the origin id - const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop(); - if (origin_id) { - attrs['origin_id'] = origin_id.getAttribute('id'); - } return attrs; } @@ -143,33 +145,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); } /** diff --git a/src/headless/shared/types.ts b/src/headless/shared/types.ts index 0355c605d3..af06abd127 100644 --- a/src/headless/shared/types.ts +++ b/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; diff --git a/src/headless/types/plugins/chat/message.d.ts b/src/headless/types/plugins/chat/message.d.ts index 21fed80571..744251eea7 100644 --- a/src/headless/types/plugins/chat/message.d.ts +++ b/src/headless/types/plugins/chat/message.d.ts @@ -188,6 +188,10 @@ declare class Message extends Message_base { * @returns {boolean} */ isMeCommand(): boolean; + /** + * @returns {boolean} + */ + isRetracted(): boolean; /** * Returns a boolean indicating whether this message is considered a followup * message from the previous one. Followup messages are shown grouped together diff --git a/src/headless/types/plugins/muc/message.d.ts b/src/headless/types/plugins/muc/message.d.ts index 0e9bc21ff3..fe7b7f1538 100644 --- a/src/headless/types/plugins/muc/message.d.ts +++ b/src/headless/types/plugins/muc/message.d.ts @@ -4,11 +4,10 @@ declare class MUCMessage extends Message { /** * Determines whether this messsage may be moderated, * based on configuration settings and server support. - * @async * @method _converse.ChatRoomMessages#mayBeModerated - * @returns {boolean} + * @returns {Promise} */ - mayBeModerated(): boolean; + mayBeModerated(): Promise; checkValidity(): any; onOccupantRemoved(): void; /** 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/headless/types/shared/actions.d.ts b/src/headless/types/shared/actions.d.ts index 61bb81c14f..12ad0601ff 100644 --- a/src/headless/types/shared/actions.d.ts +++ b/src/headless/types/shared/actions.d.ts @@ -30,6 +30,7 @@ export function sendChatState(jid: string, chat_state: string): void; * 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: string, message: import("../plugins/chat/message").default): any; +export function sendRetractionMessage(jid: string, message: import("../plugins/chat/message").default, retraction_id: string): any; //# sourceMappingURL=actions.d.ts.map \ No newline at end of file diff --git a/src/headless/types/shared/model-with-messages.d.ts b/src/headless/types/shared/model-with-messages.d.ts index 07087d5486..cfa109a3ed 100644 --- a/src/headless/types/shared/model-with-messages.d.ts +++ b/src/headless/types/shared/model-with-messages.d.ts @@ -87,7 +87,7 @@ export default function ModelWithMessages; /** diff --git a/src/headless/types/shared/parsers.d.ts b/src/headless/types/shared/parsers.d.ts index 70437cf4e7..33f99cb2b9 100644 --- a/src/headless/types/shared/parsers.d.ts +++ b/src/headless/types/shared/parsers.d.ts @@ -24,9 +24,16 @@ export function getEncryptionAttributes(stanza: Element): import("./types").Encr * @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 getDeprecatedRetractionAttributes(stanza: Element, original_stanza: Element): import("./types").RetractionAttrs | {}; +/** + * @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: Element, original_stanza: Element): any; +export function getRetractionAttributes(stanza: Element, original_stanza: Element): import("./types").RetractionAttrs | {}; /** * @param {Element} stanza * @param {Element} original_stanza diff --git a/src/headless/types/shared/types.d.ts b/src/headless/types/shared/types.d.ts index 7790477d90..3d4d666efe 100644 --- a/src/headless/types/shared/types.d.ts +++ b/src/headless/types/shared/types.d.ts @@ -5,6 +5,13 @@ type EncryptionPayloadAttrs = { prekey?: boolean; device_id: string; }; +export type RetractionAttrs = { + editable: boolean; + is_tombstone?: boolean; + retracted: string; + retracted_id?: string; + retraction_id?: string; +}; export type EncryptionAttrs = { encrypted?: EncryptionPayloadAttrs; is_encrypted: boolean; diff --git a/src/plugins/chatview/tests/deprecated-retractions.js b/src/plugins/chatview/tests/deprecated-retractions.js new file mode 100644 index 0000000000..8a1e4ff443 --- /dev/null +++ b/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` + + 😊 + + + + `; + + _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` + + + + + `; + _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 .retraction'); + expect(msg_el.firstElementChild.textContent.trim()).toBe('Mercutio has removed a message'); + }) + ); +}); diff --git a/src/plugins/chatview/tests/message-images.js b/src/plugins/chatview/tests/message-images.js index 517fd126a5..02bc6f4592 100644 --- a/src/plugins/chatview/tests/message-images.js +++ b/src/plugins/chatview/tests/message-images.js @@ -178,7 +178,7 @@ describe("A Chat Message", function () { spyOn(view.model, 'sendMessage').and.callThrough(); await mock.sendMessage(view, message); expect(view.model.sendMessage).toHaveBeenCalled(); - await u.waitUntil(() => view.querySelector('.chat-content .chat-msg'), 1000); + await u.waitUntil(() => view.querySelector('.chat-content .chat-msg')); const msg = view.querySelector('.chat-content .chat-msg .chat-msg__text'); await u.waitUntil(() => msg.innerHTML.replace(//g, '').trim() == `https://pbs.twimg.com/media/string?format=jpg&name=small`, 1000); diff --git a/src/plugins/chatview/tests/retractions.js b/src/plugins/chatview/tests/retractions.js new file mode 100644 index 0000000000..e1bfbb700d --- /dev/null +++ b/src/plugins/chatview/tests/retractions.js @@ -0,0 +1,359 @@ +/*global mock, converse */ + +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 retract')).pop() + ); + expect(retraction_stanza).toEqualStanza(stx` + + + /me retracted a 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 .retraction'); + expect(el.firstElementChild.textContent.trim()).toBe('You have removed a 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); + + const received_stanza = stx` + + 😊 + + + + `; + + _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` + + + + /me retracted a 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 .retraction'); + expect(msg_el.firstElementChild.textContent.trim()).toBe('Mercutio has removed a 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` + + + + /me retracted a 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` + + This message will be retracted + + + + `; + _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 .retraction'); + expect(msg_el.firstElementChild.textContent.trim()).toBe('Mercutio has removed a message'); + }) + ); +}); + +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` + + + + /me retracted a previous message, but it's unsupported by your client. + + `; + + _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` + + Hello world + + + + + `; + _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'); + }) + ); + + 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` + + + + + + 😊 + + + + `; + _converse.api.connection.get()._dataRecv(mock.createRequest(first_message)); + + const tombstone = stx` + + + + + + + + + + `; + _converse.api.connection.get()._dataRecv(mock.createRequest(tombstone)); + + const last_id = u.getUniqueId(); + const retraction = stx` + + + + + + + + /me retracted a previous message, but it's unsupported by your client. + + + + `; + _converse.api.connection.get()._dataRecv(mock.createRequest(retraction)); + + const iq_result = stx` + + + + ${first_id} + ${last_id} + 2 + + + `; + _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 .retraction'); + expect(el.firstElementChild.textContent.trim()).toBe('Mercutio has removed a message'); + expect(u.hasClass('chat-msg--followup', el.parentElement)).toBe(false); + }) + ); +}); + +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` + + 😊 + + + + `; + + _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); + + const msgid = '2e972ea0-0050-44b7-a830-f6638a2595b3'; + + stanza = stx` + + This message will be retracted + + + + `; + + _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` + + + + /me retracted a previous message, but it's unsupported by your client. + + `; + _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 .retraction span'); + expect(msg_el.textContent.trim()).toBe('Mercutio has removed a message'); + }) + ); +}); diff --git a/src/plugins/muc-views/templates/mep-message.js b/src/plugins/muc-views/templates/mep-message.js index 471d17005f..641737b059 100644 --- a/src/plugins/muc-views/templates/mep-message.js +++ b/src/plugins/muc-views/templates/mep-message.js @@ -14,7 +14,7 @@ export default (el) => {
- ${ el.isRetracted() ? el.renderRetraction() : html` + ${ el.model.isRetracted() ? el.renderRetraction() : html` { `}
diff --git a/src/plugins/muc-views/tests/deprecated-retractions.js b/src/plugins/muc-views/tests/deprecated-retractions.js new file mode 100644 index 0000000000..cf049eeadd --- /dev/null +++ b/src/plugins/muc-views/tests/deprecated-retractions.js @@ -0,0 +1,623 @@ +/*global mock, converse */ +const { Strophe, u, stx } = converse.env; + +describe("Deprecated 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", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE0]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const received_stanza = stx` + + Hello world + + + `; + 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` + + + + + + `; + 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.MODERATE0]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const retraction_stanza = stx` + + + + + + `; + 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` + + Hello world + + + + + `; + _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.MODERATE0]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const retraction_stanza = stx` + + + + + Insults + + + + `; + 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` + + Hello world + + + `; + + _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` + + + + + + `; + + _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` + + Hello world + + + + + `; + _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` + + 😊 + + + + `; + + _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` + + This message will be retracted + + + + `; + + _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` + + + + + `; + _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 .retraction'); + expect(msg_el.firstElementChild.textContent.trim()).toBe('Mercutio has removed a message'); + })); + }); + + 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.MODERATE0]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const received_stanza = stx` + + Hello world + + + `; + 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` + + + + + `; + _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 .retraction'); + expect(msg_el.firstElementChild.textContent.trim()).toBe('eve has removed a message'); + expect(msg_el.querySelector('.chat-msg--retracted q')).toBe(null); + })); + + 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` + + Visit this site to get free Bitcoin! + + `; + 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); + })); + }); + + + 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` + + + + + + + 😊 + + + + `; + _converse.api.connection.get()._dataRecv(mock.createRequest(first_message)); + + const tombstone = stx` + + + + + + + + + + + `; + _converse.api.connection.get()._dataRecv(mock.createRequest(tombstone)); + + const last_id = u.getUniqueId(); + const retraction = stx` + + + + + + + + + + + + `; + _converse.api.connection.get()._dataRecv(mock.createRequest(retraction)); + + const iq_result = stx` + + + + ${first_id} + ${last_id} + 2 + + + `; + _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 .retraction'); + expect(el.firstElementChild.textContent.trim()).toBe('Mercutio has removed a 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.MODERATE0]; + 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` + + + + + + + + + + + `; + spyOn(view.model, 'handleRetraction').and.callThrough(); + _converse.api.connection.get()._dataRecv(mock.createRequest(tombstone)); + + const last_id = u.getUniqueId(); + const retraction = stx` + + + + + + + + + + + + `; + _converse.api.connection.get()._dataRecv(mock.createRequest(retraction)); + + const iq_result = stx` + + + + ${first_id} + ${last_id} + 2 + + + `; + _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 .retraction'); + expect(el.firstElementChild.textContent.trim()).toBe('eve has removed a 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.MODERATE0]; + 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` + + + + + + + + This message contains inappropriate content + + + + + `; + spyOn(view.model, 'handleModeration').and.callThrough(); + _converse.api.connection.get()._dataRecv(mock.createRequest(tombstone)); + + const last_id = u.getUniqueId(); + const retraction = stx` + + + + + + + + + This message contains inappropriate content + + + + + + `; + _converse.api.connection.get()._dataRecv(mock.createRequest(retraction)); + + const iq_result = stx` + + + + ${first_id} + ${last_id} + 2 + + + `; + _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 .retraction'); + expect(el.firstElementChild.textContent.trim()).toBe('A moderator has removed a message'); + const qel = view.querySelector('.chat-msg--retracted .chat-msg__message q'); + expect(qel.textContent.trim()).toBe('This message contains inappropriate content'); + })); + }); +}); diff --git a/src/plugins/muc-views/tests/mep.js b/src/plugins/muc-views/tests/mep.js index 93aa9cfca9..d60a7e70a7 100644 --- a/src/plugins/muc-views/tests/mep.js +++ b/src/plugins/muc-views/tests/mep.js @@ -1,8 +1,8 @@ /*global mock, converse */ - const { u, Strophe, stx } = converse.env; describe("A XEP-0316 MEP notification", function () { + beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza })); it("is rendered as an info message", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { @@ -216,19 +216,16 @@ describe("A XEP-0316 MEP notification", function () { 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 stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq moderate')).pop()); const message = view.model.messages.at(0); const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); - expect(Strophe.serialize(stanza)).toBe( - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``); + expect(stanza).toEqualStanza(stx` + + + + + `); // The server responds with a retraction message const retraction = stx` @@ -237,20 +234,17 @@ describe("A XEP-0316 MEP notification", function () { from="${muc_jid}" to="${muc_jid}/${nick}" xmlns="jabber:client"> - - - - - - + + + `; 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(''); + expect(view.model.messages.at(0).get('moderation_reason')).toBeUndefined; expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); expect(view.model.messages.at(0).get('editable')).toBe(false); - const msg_el = view.querySelector('.chat-msg--retracted .chat-info__message div'); - expect(msg_el.textContent).toBe(`${nick} has removed this message`); + const msg_el = view.querySelector('.chat-msg--retracted .chat-info__message .retraction'); + expect(msg_el.firstElementChild.textContent).toBe(`${nick} has removed a message`); })); }); diff --git a/src/plugins/muc-views/tests/retractions.js b/src/plugins/muc-views/tests/retractions.js index d2bc11f5b9..6fb0aad8b8 100644 --- a/src/plugins/muc-views/tests/retractions.js +++ b/src/plugins/muc-views/tests/retractions.js @@ -26,11 +26,13 @@ async function sendAndThenRetractMessage (_converse, view) { const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); submit_button.click(); const sent_stanzas = _converse.api.connection.get().sent_stanzas; - return u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + return u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message retract')).pop()); } 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", @@ -62,9 +64,8 @@ describe("Message Retractions", function () { from="${muc_jid}/mallory" to="${muc_jid}/romeo" xmlns="jabber:client"> - - - + + /me retracted a message `; spyOn(view.model, 'handleRetraction').and.callThrough(); @@ -96,11 +97,11 @@ describe("Message Retractions", function () { from="${muc_jid}/eve" to="${muc_jid}/romeo" xmlns="jabber:client"> - - - - - `; + + + /me retracted a message + + `; const view = _converse.chatboxviews.get(muc_jid); spyOn(converse.env.log, 'warn'); spyOn(view.model, 'handleRetraction').and.callThrough(); @@ -123,8 +124,7 @@ describe("Message Retractions", function () { - - `; + `; _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); @@ -152,12 +152,10 @@ describe("Message Retractions", function () { await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); const retraction_stanza = stx` - - - - Insults - - + + + Insults + `; const view = _converse.chatboxviews.get(muc_jid); @@ -201,170 +199,6 @@ describe("Message Retractions", function () { }); - 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` - - - - - - `; - - _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` - - Hello world - - - - - `; - _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` - - 😊 - - - - `; - - _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` - - This message will be retracted - - - - `; - - _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` - - - - - `; - _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 Sent Chat Message", function () { - - it("can be retracted by its author", mock.initConverse(['chatBoxesFetched'], { vcard: { nickname: ''} }, 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(Strophe.serialize(retraction_stanza)).toBe( - ``+ - ``+ - ``+ - ``+ - ``+ - ``); - - 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'); - })); - }); - - describe("A Received Groupchat Message", function () { it("can be followed up by a retraction by the author", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { @@ -394,9 +228,11 @@ describe("Message Retractions", function () { from="${muc_jid}/eve" to="${muc_jid}/romeo" xmlns="jabber:client"> - - - + + + + /me retracted a previous message, but it's unsupported by your client. + `; _converse.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza)); @@ -406,8 +242,8 @@ describe("Message Retractions", function () { 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'); + const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message .retraction'); + expect(msg_el?.textContent.trim()).toBe('eve has removed a message'); expect(msg_el.querySelector('.chat-msg--retracted q')).toBe(null); })); @@ -448,19 +284,18 @@ describe("Message Retractions", function () { 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 stanza = await u.waitUntil( + () => sent_IQs.filter(iq => iq.querySelector('iq retract')).pop()); const message = view.model.messages.at(0); const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); - expect(Strophe.serialize(stanza)).toBe( - ``+ - ``+ - ``+ - ``+ - `This content is inappropriate for this forum!`+ - ``+ - ``+ - ``); + expect(stanza).toEqualStanza(stx` + + + + This content is inappropriate for this forum! + + `); const result_iq = stx` - - - + + ${reason} - - + `; await view.model.handleMessageStanza(retraction); expect(view.model.messages.length).toBe(1); @@ -532,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) { @@ -567,7 +399,7 @@ describe("Message Retractions", function () { 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 stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq retract')).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 @@ -577,12 +409,10 @@ describe("Message Retractions", function () { from="${muc_jid}" to="${muc_jid}/romeo" xmlns="jabber:client"> - - - - ${reason} - - + + + ${reason} + `; await view.model.handleMessageStanza(retraction); @@ -590,8 +420,8 @@ describe("Message Retractions", function () { 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 msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message .retraction'); + expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo 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!'); @@ -608,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); + })); }); @@ -627,29 +536,31 @@ describe("Message Retractions", function () { const msg_obj = view.model.messages.last(); expect(msg_obj.get('retracted')).toBeTruthy(); - expect(Strophe.serialize(retraction_stanza)).toBe( - ``+ - ``+ - ``+ - ``+ - ``+ - ``); + expect(retraction_stanza).toEqualStanza(stx` + + + /me retracted a message + + + `); const message = view.model.messages.last(); expect(message.get('is_ephemeral')).toBe(false); expect(message.get('editable')).toBeFalsy(); - const stanza_id = message.get(`stanza_id ${muc_jid}`); // The server responds with a retraction message + const stanza_id = '5f3dbc5e-e1d3-4077-a492-693f3769c7ad'; const reflection = stx` - - - + + + /me retracted a message + + `; spyOn(view.model, 'handleRetraction').and.callThrough(); @@ -660,9 +571,10 @@ describe("Message Retractions", function () { expect(view.model.messages.last().get('retracted')).toBeTruthy(); expect(view.model.messages.last().get('is_ephemeral')).toBe(false); expect(view.model.messages.last().get('editable')).toBe(false); + expect(message.get(`stanza_id ${muc_jid}`)).toBe(stanza_id); 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'); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message .retraction'); + expect(el?.textContent.trim()).toBe('You have removed a message'); })); it("can be retracted by its author, causing an error message in response", @@ -682,8 +594,8 @@ 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'); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message .retraction'); + expect(el?.textContent.trim()).toBe('You have removed a message'); const message = view.model.messages.last(); const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); @@ -697,9 +609,7 @@ describe("Message Retractions", function () { - - - + `; _converse.api.connection.get()._dataRecv(mock.createRequest(error)); @@ -730,8 +640,8 @@ describe("Message Retractions", function () { expect(view.model.messages.length).toBe(1); 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'); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message .retraction'); + expect(el?.textContent.trim()).toBe('You have removed a message'); await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); @@ -744,7 +654,7 @@ describe("Message Retractions", function () { const error_messages = view.querySelectorAll('.chat-msg__error'); expect(error_messages.length).toBe(1); expect(error_messages[0].textContent.trim()).toBe( - 'Message delivery failed.\nA timeout happened while while trying to retract your message.'); + 'Message delivery failed.\nA timeout happened while trying to retract your message.'); })); @@ -784,12 +694,11 @@ describe("Message Retractions", function () { from="${muc_jid}" to="${muc_jid}/romeo" xmlns="jabber:client"> - - - + + + ${reason} - - + `; await view.model.handleMessageStanza(retraction); expect(view.model.messages.length).toBe(1); @@ -809,7 +718,7 @@ describe("Message Retractions", function () { const occupant = view.model.getOwnOccupant(); expect(occupant.get('role')).toBe('moderator'); - view.model.sendMessage({'body': 'Visit this site to get free bitcoin'}); + view.model.sendMessage({body: 'Visit this site to get free bitcoin'}); await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); // Check that you can only edit a message before it's been @@ -843,17 +752,14 @@ describe("Message Retractions", function () { 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()); - - expect(Strophe.serialize(stanza)).toBe( - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``); + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq moderate')).pop()); + + expect(stanza).toEqualStanza(stx` + + + + + `); const result_iq = stx` - - - - - + + + `; await view.model.handleMessageStanza(retraction); expect(view.model.messages.length).toBe(1); @@ -901,94 +805,6 @@ describe("Message Retractions", function () { 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` - - - - - - - 😊 - - - - `; - _converse.api.connection.get()._dataRecv(mock.createRequest(first_message)); - - const tombstone = stx` - - - - - - - - - - - `; - _converse.api.connection.get()._dataRecv(mock.createRequest(tombstone)); - - const last_id = u.getUniqueId(); - const retraction = stx` - - - - - - - - - - - - `; - _converse.api.connection.get()._dataRecv(mock.createRequest(retraction)); - - const iq_result = stx` - - - - ${first_id} - ${last_id} - 2 - - - `; - _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'], {}, @@ -1010,8 +826,7 @@ describe("Message Retractions", function () { - - + @@ -1026,9 +841,9 @@ describe("Message Retractions", function () { - - - + + + /me retracted a previous message, but it's unsupported by your client. @@ -1062,8 +877,8 @@ describe("Message Retractions", function () { 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'); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message .retraction'); + expect(el?.textContent.trim()).toBe('eve has removed a message'); })); it("may be returned as a tombstone moderated groupchat message", @@ -1087,10 +902,10 @@ describe("Message Retractions", function () { - - - This message contains inappropriate content - + + + This message contains inappropriate content for this forum + @@ -1105,12 +920,10 @@ describe("Message Retractions", function () { - - - - This message contains inappropriate content - - + + + This message contains inappropriate content + @@ -1149,8 +962,8 @@ describe("Message Retractions", function () { 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 el = view.querySelector('.chat-msg--retracted .chat-msg__message .retraction'); + expect(el.firstElementChild.textContent.trim()).toBe('A moderator has removed a message'); const qel = view.querySelector('.chat-msg--retracted .chat-msg__message q'); expect(qel.textContent.trim()).toBe('This message contains inappropriate content'); })); diff --git a/src/shared/chat/message.js b/src/shared/chat/message.js index 6da3d822c6..cf51c937e8 100644 --- a/src/shared/chat/message.js +++ b/src/shared/chat/message.js @@ -123,10 +123,6 @@ export default class Message extends CustomElement { this.parentElement.removeChild(this); } - isRetracted () { - return this.model.get('retracted') || this.model.get('moderated') === 'retracted'; - } - hasMentions () { const is_groupchat = this.model.get('type') === 'groupchat'; return is_groupchat && this.model.get('sender') === 'them' && this.model_with_messages.isUserMentioned(this.model); @@ -141,11 +137,12 @@ export default class Message extends CustomElement { } getExtraMessageClasses () { + const is_action = this.model.isMeCommand() || this.model.isRetracted(); const extra_classes = [ this.model.isFollowup() ? 'chat-msg--followup' : null, this.model.get('is_delayed') ? 'delayed' : null, - this.model.isMeCommand() ? 'chat-msg--action' : null, - this.isRetracted() ? 'chat-msg--retracted' : null, + is_action ? 'chat-msg--action' : null, + this.model.isRetracted() ? 'chat-msg--retracted' : null, this.model.get('type'), this.shouldShowAvatar() ? 'chat-msg--with-avatar' : null, ].map(c => c); @@ -171,9 +168,11 @@ export default class Message extends CustomElement { occupants.findOccupant({'nick': Strophe.getResourceFromJid(retracted_by_mod)}); } const modname = this.model.mod ? this.model.mod.getDisplayName() : __('A moderator'); - return __('%1$s has removed this message', modname); + return __('%1$s has removed a message', modname); } else { - return __('%1$s has removed this message', this.model.getDisplayName()); + return this.model.get('sender') === 'me' ? + __('You have removed a message') : + __('%1$s has removed a message', this.model.getDisplayName()); } } diff --git a/src/shared/chat/templates/message.js b/src/shared/chat/templates/message.js index 0a3b309980..c8a4179919 100644 --- a/src/shared/chat/templates/message.js +++ b/src/shared/chat/templates/message.js @@ -19,7 +19,7 @@ export default (el) => { const is_first_unread = el.model_with_messages.get('first_unread_id') === el.model.get('id'); const is_followup = el.model.isFollowup(); const is_me_message = el.model.isMeCommand(); - const is_retracted = el.isRetracted(); + const is_retracted = el.model.isRetracted(); const msgid = el.model.get('msgid'); const sender = el.model.get('sender'); const time = el.model.get('time'); @@ -30,7 +30,10 @@ export default (el) => { const pretty_time = dayjs(edited || time).format(format); const hats = getHats(el.model); const username = el.model.getDisplayName(); - const should_show_avatar = el.shouldShowAvatar(); + + const is_action = is_me_message || is_retracted; + const should_show_header = !is_action && !is_followup; + const should_show_avatar = el.shouldShowAvatar() && should_show_header; // The model to use for the avatar. // Note: it can happen that the contact has not the vcard attribute but the message has. @@ -52,7 +55,7 @@ export default (el) => { - ${should_show_avatar && !is_followup + ${should_show_avatar ? html` { ` : ''} -
- ${!is_me_message && !is_followup +
+ ${should_show_header ? html` { : ''} ${el.model.get('is_delayed') ? 'chat-msg__body--delayed' : ''}" >
- ${is_me_message - ? html`   - ${is_me_message ? '**' : ''}${username} ` + ${is_action + ? html` + ${is_me_message + ? html`${is_me_message ? '**' : ''}${username} ` + : ''}` : ''} ${is_retracted ? el.renderRetraction() : el.renderMessageText()}
diff --git a/src/shared/chat/templates/retraction.js b/src/shared/chat/templates/retraction.js index 23b571cdef..b5c236584d 100644 --- a/src/shared/chat/templates/retraction.js +++ b/src/shared/chat/templates/retraction.js @@ -2,10 +2,17 @@ import { html } from 'lit'; import '../styles/retraction.scss'; +/** + * @param {import('shared/chat/message').default} el + */ export default (el) => { - const retraction_text = el.isRetracted() ? el.getRetractionText() : null; - return html` -
${retraction_text}
- ${ el.model.get('moderation_reason') ? - html`${el.model.get('moderation_reason')}` : '' }`; -} + const retraction_text = el.model.isRetracted() ? el.getRetractionText() : null; + return html` + ${retraction_text} + ${el.model.get('moderation_reason') + ? html`${el.model.get('moderation_reason')}` + : ''} + `; +}; diff --git a/src/shared/styles/messages.scss b/src/shared/styles/messages.scss index 99c6470ce9..4534e2d739 100644 --- a/src/shared/styles/messages.scss +++ b/src/shared/styles/messages.scss @@ -280,14 +280,24 @@ font-size: var(--message-font-size); } .chat-msg__time { + margin-inline-end: 0.5em; margin-inline-start: 0; } + + .retraction { + display: flex; + flex-direction: column; + } } .chat-msg__content { width: calc(100% - var(--message-avatar-width)); } + .chat-msg__content--action { + width: 100%; + } + &.chat-msg--followup { .chat-msg__heading, .show-msg-author-modal { diff --git a/src/shared/styles/themes/cyberpunk.scss b/src/shared/styles/themes/cyberpunk.scss index 2db76c1f2a..b2291c333e 100644 --- a/src/shared/styles/themes/cyberpunk.scss +++ b/src/shared/styles/themes/cyberpunk.scss @@ -26,7 +26,7 @@ // Bootstrap variables --primary-color: var(--purple) !important; - --secondary-color: var(--indigo) !important; + --secondary-color: var(--pink) !important; --success-color: var(--green); --danger-color: var(--red); --warning-color: var(--orange); @@ -44,7 +44,7 @@ --error-color: var(--red); --focus-color: var(--secondary-color); --heading-color: var(--purple); - --headlines-color: var(--pink); + --headlines-color: var(--indigo); --link-color: var(--cyan); // The background when selecting text with your mouse @@ -77,6 +77,10 @@ --success-color-hover: var(--success); --warning-color-hover: var(--orange); + .toggle-controlbox { + color: var(--foreground-color) !important; + } + #controlbox { .flyout { box-shadow: @@ -92,12 +96,15 @@ } } - .message .separator-text { - border-radius: 50px; - box-shadow: - 0 0 0.25rem rgba(60, 242, 129, 0.8), - 0 0 1rem rgba(60, 242, 129, 0.2), - 0 0 4rem rgba(60, 242, 129, 0.1) !important; + .date-separator { + margin-bottom: 1em !important; + .separator-text { + border-radius: 50px; + box-shadow: + 0 0 0.25rem rgba(60, 242, 129, 0.8), + 0 0 1rem rgba(60, 242, 129, 0.2), + 0 0 4rem rgba(60, 242, 129, 0.1) !important; + } } .card { diff --git a/src/types/shared/chat/message.d.ts b/src/types/shared/chat/message.d.ts index 8e0b7f359e..d50f1f54df 100644 --- a/src/types/shared/chat/message.d.ts +++ b/src/types/shared/chat/message.d.ts @@ -21,7 +21,6 @@ export default class Message extends CustomElement { onUnfurlAnimationEnd(): void; onRetryClicked(): Promise; show_spinner: boolean; - isRetracted(): any; hasMentions(): any; getOccupantAffiliation(): any; getOccupantRole(): any; diff --git a/src/types/shared/chat/templates/retraction.d.ts b/src/types/shared/chat/templates/retraction.d.ts index d373f8270b..0083c52978 100644 --- a/src/types/shared/chat/templates/retraction.d.ts +++ b/src/types/shared/chat/templates/retraction.d.ts @@ -1,3 +1,3 @@ -declare function _default(el: any): import("lit").TemplateResult<1>; +declare function _default(el: import("shared/chat/message").default): import("lit").TemplateResult<1>; export default _default; //# sourceMappingURL=retraction.d.ts.map \ No newline at end of file