diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index ddc998bbf..bfa899d3e 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -168,7 +168,7 @@ allow_message_retraction ------------------------ * Default: ``'all'`` -* Possible values: ``'all'``, ``'own'``, ``'moderator'`` +* Possible values: ``'all'``, ``'own'``, ``'moderator'`` or any falsy value Determines who is allowed to retract messages. If set to ``'all'``, then normal users may retract their own messages and ``'moderators'`` may retract the messages of diff --git a/package-lock.json b/package-lock.json index 9d97d3ebb..3fd3398f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9980,6 +9980,11 @@ "dev": true, "optional": true }, + "filesize": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-4.2.1.tgz", + "integrity": "sha512-bP82Hi8VRZX/TUBKfE24iiUGsB/sfm2WUrwTQyAzQrhO3V9IhcBBNBXMyzLY5orACxRyYJ3d2HeRVX+eFv4lmA==" + }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", @@ -10291,7 +10296,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, "requires": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", @@ -10301,8 +10305,7 @@ "graceful-fs": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", - "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", - "dev": true + "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==" } } }, @@ -11527,8 +11530,7 @@ "graceful-fs": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", - "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=", - "dev": true + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=" }, "handle-thing": { "version": "2.0.0", @@ -11996,6 +11998,11 @@ "minimatch": "^3.0.4" } }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" + }, "import-cwd": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", @@ -12559,6 +12566,11 @@ "integrity": "sha1-5kAN8ea1bhMLYcS80JPap/boyhU=", "dev": true }, + "jed": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/jed/-/jed-1.1.1.tgz", + "integrity": "sha1-elSbvZ/+FYWwzQoZHiAwVb7ldLQ=" + }, "jest-worker": { "version": "25.1.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-25.1.0.tgz", @@ -12718,7 +12730,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", - "dev": true, "requires": { "graceful-fs": "^4.1.6" } @@ -12955,6 +12966,14 @@ "type-check": "~0.3.2" } }, + "lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=", + "requires": { + "immediate": "~3.0.5" + } + }, "linkify-it": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz", @@ -13017,6 +13036,14 @@ "json5": "^0.5.0" } }, + "localforage": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.7.3.tgz", + "integrity": "sha512-1TulyYfc4udS7ECSBT2vwJksWbkwwTX8BzeUIiq8Y07Riy7bDAAnxDaPU/tWyOVmQAcWJIEIFP9lPfBGqVoPgQ==", + "requires": { + "lie": "3.1.1" + } + }, "locate-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", @@ -18771,6 +18798,14 @@ } } }, + "pluggable.js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pluggable.js/-/pluggable.js-2.0.1.tgz", + "integrity": "sha512-SBt6v6Tbp20Jf8hU0cpcc/+HBHGMY8/Q+yA6Ih0tBQE8tfdZ6U4PRG0iNvUUjLx/hVyOP53n0UfGBymlfaaXCg==", + "requires": { + "lodash": "^4.17.11" + } + }, "po-loader": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/po-loader/-/po-loader-0.5.0.tgz", @@ -20679,6 +20714,13 @@ "integrity": "sha512-Mf37VjirD7RqCVeYgI8jb5K0DymIho/jNJqDgIkMs4cgKbEkvsow8Q6hpvF7Zmys9iEif0oW41hgbeWVZwABJw==", "dev": true }, + "skeletor.js": { + "version": "github:skeletorjs/skeletor#bf6d9c86f9fcf224fa9d9af5a25380b77aa4b561", + "from": "github:skeletorjs/skeletor#bf6d9c86f9fcf224fa9d9af5a25380b77aa4b561", + "requires": { + "lodash": "^4.17.14" + } + }, "slash": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", @@ -21353,6 +21395,11 @@ "through": "^2.3.4" } }, + "strophe.js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/strophe.js/-/strophe.js-1.3.4.tgz", + "integrity": "sha512-jSLDG8jolhAwGOSgiJ7DTMSYK3wVoEJHKtpVRyEacQZ6CWA6z2WRPJpcFMjsIweq5aP9/XIvKUQqHBu/ZhvESA==" + }, "style-loader": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-0.23.1.tgz", @@ -21806,6 +21853,33 @@ "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", "dev": true }, + "twemoji": { + "version": "12.1.5", + "resolved": "https://registry.npmjs.org/twemoji/-/twemoji-12.1.5.tgz", + "integrity": "sha512-B0PBVy5xomwb1M/WZxf/IqPZfnoIYy1skXnlHjMwLwTNfZ9ljh8VgWQktAPcJXu8080WoEh6YwQGPVhDVqvrVQ==", + "requires": { + "fs-extra": "^8.0.1", + "jsonfile": "^5.0.0", + "twemoji-parser": "12.1.3", + "universalify": "^0.1.2" + }, + "dependencies": { + "jsonfile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz", + "integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==", + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^0.1.2" + } + } + } + }, + "twemoji-parser": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-12.1.3.tgz", + "integrity": "sha512-ND4LZXF4X92/PFrzSgGkq6KPPg8swy/U0yRw1k/+izWRVmq1HYi3khPwV3XIB6FRudgVICAaBhJfW8e8G3HC7Q==" + }, "type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", @@ -22026,8 +22100,7 @@ "universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" }, "unpipe": { "version": "1.0.0", diff --git a/spec/retractions.js b/spec/retractions.js index c08d8a4b2..069bd93aa 100644 --- a/spec/retractions.js +++ b/spec/retractions.js @@ -802,6 +802,91 @@ expect(view.model.messages.at(0).get('editable')).toBe(false); done(); })); + + it("can be retracted by the sender if they're a moderator", + mock.initConverse( + ['rosterGroupsFetched', 'chatBoxesFetched'], {'allow_message_retraction': 'moderator'}, + async function (done, _converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.api.chatviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + + view.model.sendMessage('Visit this site to get free bitcoin'); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1); + const stanza_id = 'retraction-id-1'; + const msg_obj = view.model.messages.at(0); + const reflection_stanza = u.toStanza(` + + ${msg_obj.get('message')} + + + `); + await view.model.queueMessage(reflection_stanza); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('editable')).toBe(true); + + const retract_button = await u.waitUntil(() => view.msgs_container.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_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + + expect(Strophe.serialize(stanza)).toBe( + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``); + + const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'}); + _converse.connection._dataRecv(test_utils.createRequest(result_iq)); + + // We opportunistically save the message as retracted, even before receiving the retraction message + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(undefined); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1); + + const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo has removed this message'); + expect(msg_el.querySelector('q')).toBe(null); + + // The server responds with a retraction message + const retraction = u.toStanza(` + + + + + + + `); + await view.model.queueMessage(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(undefined); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + done(); + })); }); diff --git a/src/converse-message-view.js b/src/converse-message-view.js index 9cae82400..450663729 100644 --- a/src/converse-message-view.js +++ b/src/converse-message-view.js @@ -230,17 +230,8 @@ converse.plugins.add('converse-message-view', { const role = this.model.vcard ? this.model.vcard.get('role') : null; const roles = role ? role.split(',') : []; const is_retracted = this.model.get('retracted') || this.model.get('moderated') === 'retracted'; - const is_groupchat = this.model.get('type') === 'groupchat'; - const is_own_message = this.model.get('sender') === 'me'; - const chatbox = this.model.collection.chatbox; - const may_retract_own_message = is_own_message && ( - ['all', 'own'].includes(_converse.allow_message_retraction) || await chatbox.canModerateMessages() - ); - const may_moderate_message = !is_own_message && is_groupchat && - ['all', 'moderator'].includes(_converse.allow_message_retraction) && - await chatbox.canModerateMessages(); - - const retractable= !is_retracted && (may_moderate_message || may_retract_own_message); + const may_be_moderated = this.model.get('type') === 'groupchat' && await this.model.mayBeModerated(); + const retractable= !is_retracted && (this.model.mayBeRetracted() || may_be_moderated); const msg = u.stringToElement(tpl_message( Object.assign( this.model.toJSON(), { @@ -248,7 +239,7 @@ converse.plugins.add('converse-message-view', { is_retracted, retractable, 'extra_classes': this.getExtraMessageClasses(), - 'is_groupchat_message': is_groupchat, + 'is_groupchat_message': this.model.get('type') === 'groupchat', 'is_me_message': this.model.isMeCommand(), 'label_show': __('Show more'), 'occupant': this.model.occupant, diff --git a/src/converse-muc-views.js b/src/converse-muc-views.js index c1f46f7a7..ffbfbfa80 100644 --- a/src/converse-muc-views.js +++ b/src/converse-muc-views.js @@ -985,7 +985,7 @@ converse.plugins.add('converse-muc-views', { "not yet support retractions and that this message may not "+ "be removed everywhere."); - if (message.get('sender') === 'me') { + if (message.mayBeRetracted()) { const messages = [__('Are you sure you want to retract this message?')]; if (_converse.show_retraction_warning) { messages[1] = retraction_warning; @@ -994,22 +994,35 @@ converse.plugins.add('converse-muc-views', { if (result) { this.retractOwnMessage(message); } + } else if (await message.mayBeModerated()) { + if (message.get('sender') === 'me') { + let messages = [__('Are you sure you want to retract this message?')]; + if (_converse.show_retraction_warning) { + messages = [messages[0], retraction_warning, messages[1]] + } + if (await _converse.api.confirm(__('Confirm'), messages)) { + this.retractOtherMessage(message); + } + } else { + let messages = [ + __('You are about to retract this message.'), + __('You may optionally include a message, explaining the reason for the retraction.') + ]; + if (_converse.show_retraction_warning) { + messages = [messages[0], retraction_warning, messages[1]] + } + const reason = await _converse.api.prompt( + __('Message Retraction'), + messages, + __('Optional reason') + ); + if (reason !== false) { + this.retractOtherMessage(message, reason); + } + } } else { - let messages = [ - __('You are about to retract this message.'), - __('You may optionally include a message, explaining the reason for the retraction.') - ]; - if (_converse.show_retraction_warning) { - messages = [messages[0], retraction_warning, messages[1]] - } - const reason = await _converse.api.prompt( - __('Message Retraction'), - messages, - __('Optional reason') - ); - if (reason !== false) { - this.retractOtherMessage(message, reason); - } + const err_msg = __(`Sorry, you're not allowed to retract this message`); + _converse.api.alert('error', __('Error'), err_msg); } }, diff --git a/src/headless/converse-chat.js b/src/headless/converse-chat.js index 3ca5e74fb..22e45bae2 100644 --- a/src/headless/converse-chat.js +++ b/src/headless/converse-chat.js @@ -142,6 +142,17 @@ converse.plugins.add('converse-chat', { return true; }, + /** + * Determines whether this messsage may be retracted by the current user. + * @private + * @method _converse.Messages#mayBeRetracted + * @returns { Boolean } + */ + mayBeRetracted () { + const is_own_message = this.get('sender') === 'me'; + return is_own_message && ['all', 'own'].includes(_converse.allow_message_retraction); + }, + safeDestroy () { try { this.destroy() diff --git a/src/headless/converse-muc.js b/src/headless/converse-muc.js index ec34c9e35..fba03036a 100644 --- a/src/headless/converse-muc.js +++ b/src/headless/converse-muc.js @@ -250,6 +250,19 @@ converse.plugins.add('converse-muc', { _converse.api.trigger('chatRoomMessageInitialized', this); }, + /** + * Determines whether this messsage may be moderated, + * based on configuration settings and server support. + * @async + * @private + * @method _converse.ChatRoomMessages#mayBeModerated + * @returns { Boolean } + */ + mayBeModerated () { + return ['all', 'moderator'].includes(_converse.allow_message_retraction) && + this.collection.chatbox.canModerateMessages(); + }, + checkValidity () { const result = _converse.Message.prototype.checkValidity.call(this); !result && this.collection.chatbox.debouncedRejoin(); @@ -757,7 +770,7 @@ converse.plugins.add('converse-muc', { 'xmlns': Strophe.NS.FASTEN }).c('moderate', {xmlns: Strophe.NS.MODERATE}) .c('retract', {xmlns: Strophe.NS.RETRACT}).up() - .c('reason').t(reason); + .c('reason').t(reason || ''); return _converse.api.sendIQ(iq, null, false); },