diff --git a/CHANGES.md b/CHANGES.md index db7f854b1..ebf28276c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,10 +2,9 @@ ## 6.0.0 (Unreleased) -- #129: Add support for XEP-0156: Disovering Alternative XMPP Connection Methods. Only XML is supported for now. -- #1105: Preliminary support for storing persistent data in IndexedDB instead of localStorage -- #1691: Fix `collection.chatbox is undefined` errors -- #1772: `_converse.api.contact.add(jid, nick)` fails, says not a function +- Add support for [XEP-0424 Message Retraction](http://localhost:3080/extensions/xep-0424.html) +- Add support for [XEP-0425 Message Moderation](http://localhost:3080/extensions/xep-0425.html) +- Prevent editing of sent file uploads. - Initial support for sending custom emojis. Currently only between Converse instances. Still working out a wire protocol for compatibility with other clients. To add custom emojis, edit the `emojis.json` file. @@ -14,6 +13,13 @@ - New config option [muc_mention_autocomplete_filter](https://conversejs.org/docs/html/configuration.html#muc_mention_autocomplete_filter) - New config option [muc_mention_autocomplete_show_avatar](https://conversejs.org/docs/html/configuration.html#muc_mention_autocomplete_show_avatar) +- #129: Add support for XEP-0156: Disovering Alternative XMPP Connection Methods. Only XML is supported for now. +- #1105: Preliminary support for storing persistent data in IndexedDB instead of localStorage +- #1691: Fix `collection.chatbox is undefined` errors +- #1733: New message notifications for a minimized chat stack on top of each other +- #1757: Chats are hidden behind the controlbox on mobile +- #1772 `_converse.api.contact.add(jid, nick)` fails, says not a function + ### Breaking changes - In contrast to sessionStorage and localStorage, IndexedDB is an asynchronous database. diff --git a/README.md b/README.md index e042b96b5..c8cf31471 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ which shows you how to use the CDN (content delivery network) to quickly get a d ## Features - Available as overlayed chat boxes or as a fullscreen application. See [inverse.chat](https://inverse.chat) for the fullscreen version. +- Custom status messages +- Desktop notifications - A [plugin architecture](https://conversejs.org/docs/html/plugin_development.html) based on [pluggable.js](https://conversejs.github.io/pluggable.js/) -- Single-user and group chats -- Contacts and groups - Multi-user chat rooms [XEP 45](https://xmpp.org/extensions/xep-0045.html) - Chatroom bookmarks [XEP 48](https://xmpp.org/extensions/xep-0048.html) - Direct invitations to chat rooms [XEP 249](https://xmpp.org/extensions/xep-0249.html) @@ -50,9 +50,7 @@ which shows you how to use the CDN (content delivery network) to quickly get a d - In-band registration [XEP 77](https://xmpp.org/extensions/xep-0077.html) - Roster item exchange [XEP 144](https://xmpp.org/extensions/tmp/xep-0144-1.1.html) - Chat statuses (online, busy, away, offline) -- Custom status messages - Typing and state notifications [XEP 85](https://xmpp.org/extensions/xep-0085.html) -- Desktop notifications - File sharing / HTTP File Upload [XEP 363](https://xmpp.org/extensions/xep-0363.html) - Messages appear in all connnected chat clients / Message Carbons [XEP 280](https://xmpp.org/extensions/xep-0280.html) - Third person "/me" messages [XEP 245](https://xmpp.org/extensions/xep-0245.html) @@ -62,8 +60,10 @@ which shows you how to use the CDN (content delivery network) to quickly get a d - Client state indication [XEP 352](https://xmpp.org/extensions/xep-0352.html) - Last Message Correction [XEP 308](https://xmpp.org/extensions/xep-0308.html) - OMEMO encrypted messaging [XEP 384](https://xmpp.org/extensions/xep-0384.html") -- Supports anonymous logins, see the [anonymous login demo](https://conversejs.org/demo/anonymous.html). -- Translated into 28 languages +- Anonymous logins, see the [anonymous login demo](https://conversejs.org/demo/anonymous.html) +- Message Retractions [XEP-424](https://xmpp.org/extensions/xep-0424.html) +- Message Moderation [XEP-425](https://xmpp.org/extensions/xep-0425.html) +- Translated into over 30 languages ## Integration into other frameworks diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 924fae2cc..88c842605 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -1475,6 +1475,26 @@ not fulfilled. Requires the `src/converse-notification.js` plugin. +show_retraction_warning +----------------------- + +* Default: ``true`` + +From `XEP-0424: Message Retraction `_: + +:: + Due to the federated and extensible nature of XMPP it's not possible to remove a message with + full certainty and a retraction can only be considered an unenforceable request for such removal. + Clients which don't support message retraction are not obligated to enforce the request and + people could have seen or copied the message contents already. + +By default Converse shows a warning to users when they retract a message, to +inform them that they don't have a guarantee that the message will be removed +everywhere. + +This warning isn't applicable to all deployments of Converse and can therefore +be turned off by setting this config variable to ``false``. + use_system_emojis ----------------- * Default: ``true`` diff --git a/index.html b/index.html index b93e8d804..06786f340 100644 --- a/index.html +++ b/index.html @@ -171,8 +171,8 @@ See inverse.chat for the fullscreen version.
  • A plugin architecture based on pluggable.js
  • -
  • Single-user and group chat
  • -
  • Contacts and groups
  • +
  • Chat statuses (online, busy, away, offline)
  • +
  • Desktop notifications
  • Multi-user chatrooms (XEP 45)
  • Chatroom bookmarks (XEP 48)
  • Direct invitations to chat rooms (XEP 249)
  • @@ -180,10 +180,8 @@
  • Service discovery (XEP 30)
  • In-band registration (XEP 77)
  • Roster item exchange (XEP 144)
  • -
  • Chat statuses (online, busy, away, offline)
  • Custom status messages
  • Typing and chat state notifications (XEP 85)
  • -
  • Desktop notifications
  • File sharing / HTTP File Upload (XEP 363)
  • Messages appear in all connected chat clients / Message Carbons (XEP 280)
  • Third person "/me" messages (XEP 245)
  • @@ -193,8 +191,10 @@
  • Client state indication (XEP 352)
  • Last Message Correction (XEP 308)
  • OMEMO encrypted messaging (XEP 384)
  • -
  • Supports anonymous logins, see the anonymous login demo.
  • -
  • Translated into 29 languages
  • +
  • Anonymous logins, see the anonymous login demo
  • +
  • Message Retractions (XEP 424)
  • +
  • Message Moderation (XEP 425)
  • +
  • Translated into over 30 languages
  • diff --git a/sass/_chatrooms.scss b/sass/_chatrooms.scss index 15da799ba..82c2f60e1 100644 --- a/sass/_chatrooms.scss +++ b/sass/_chatrooms.scss @@ -143,6 +143,9 @@ &.badge { color: var(--chat-head-text-color); } + &.chat-msg--retracted { + color: var(--subdued-color); + } } .disconnect-container { margin: 1em; diff --git a/sass/_core.scss b/sass/_core.scss index 7a0271571..ee531e40e 100644 --- a/sass/_core.scss +++ b/sass/_core.scss @@ -317,6 +317,16 @@ body.converse-fullscreen { color: var(--gray-color); } + q { + quotes: "“" "”" "‘" "’"; + } + q:before { + content: open-quote; + } + q:after { + content: close-quote; + } + .modal { background-color: rgba(0, 0, 0, 0.4); diff --git a/sass/_messages.scss b/sass/_messages.scss index ba435006e..d2f176b83 100644 --- a/sass/_messages.scss +++ b/sass/_messages.scss @@ -39,6 +39,12 @@ } } + &.chat-msg--retracted { + .chat-msg__message { + color: var(--subdued-color); + } + } + &.chat-info { color: var(--chat-head-color); font-size: var(--message-font-size); @@ -46,6 +52,9 @@ font-size: 90%; padding: 0.17rem 1rem; + &.chat-msg--followup { + margin-left: 2.75rem; + } &.badge { color: var(--chat-head-text-color); } @@ -60,6 +69,9 @@ color: var(--error-color); font-weight: bold; } + .q { + font-style: italic; + } } .chat-image { @@ -225,7 +237,7 @@ height: var(--message-font-size); font-size: var(--message-font-size); padding: 0; - padding-left: 0.5em; + padding-left: 0.75em; border: none; opacity: 0; background: transparent; @@ -336,6 +348,11 @@ } } } + &.chat-info { + &.chat-msg--followup { + margin-left: 0; + } + } } } diff --git a/sass/_modal.scss b/sass/_modal.scss index 252aa7b14..670f6e7be 100644 --- a/sass/_modal.scss +++ b/sass/_modal.scss @@ -1,8 +1,15 @@ #conversejs { #converse-modals { - .modal-body { margin-bottom: 2em; + .confirm { + .form-group { + p:first-child { + font-size: 110%; + font-weight: bold; + } + } + } } .scrollable-container { diff --git a/spec/chatbox.js b/spec/chatbox.js index 5d4d53d8d..693f89ea7 100644 --- a/spec/chatbox.js +++ b/spec/chatbox.js @@ -720,7 +720,6 @@ await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname')); await test_utils.waitForRoster(_converse, 'current'); // Send a message from a different resource - spyOn(_converse, 'log'); const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit'; const view = await test_utils.openChatBoxFor(_converse, recipient_jid); const msg = $msg({ @@ -848,7 +847,6 @@ await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname')); await test_utils.waitForRoster(_converse, 'current'); // Send a message from a different resource - spyOn(_converse, 'log'); const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit'; const view = await test_utils.openChatBoxFor(_converse, recipient_jid); const msg = $msg({ diff --git a/spec/mam.js b/spec/mam.js index d5c37607f..6b1dce512 100644 --- a/spec/mam.js +++ b/spec/mam.js @@ -1037,7 +1037,7 @@ const view = _converse.chatboxviews.get(contact_jid); expect(view.model.messages.length).toBe(1); - expect(view.model.messages.at(0).get('ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); expect(view.model.messages.at(0).get('type')).toBe('error'); expect(view.model.messages.at(0).get('message')).toBe('Timeout while trying to fetch archived messages.'); diff --git a/spec/messages.js b/spec/messages.js index 6763656c9..4bdfbe4a5 100644 --- a/spec/messages.js +++ b/spec/messages.js @@ -83,7 +83,7 @@ expect(textarea.value).toBe(''); const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'}); - expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(1); + expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2); let action = view.el.querySelector('.chat-msg .chat-msg__action'); expect(action.getAttribute('title')).toBe('Edit this message'); @@ -160,7 +160,7 @@ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree() ); await new Promise(resolve => view.once('messageInserted', resolve)); - expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(1); + expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2); // Test confirmation dialog spyOn(window, 'confirm').and.returnValue(true); diff --git a/spec/muc.js b/spec/muc.js index ce4ae2fab..24762895e 100644 --- a/spec/muc.js +++ b/spec/muc.js @@ -5232,6 +5232,7 @@ const textarea = view.el.querySelector('.chat-textarea'); textarea.value = 'Hello world'; view.onFormSubmitted(new Event('submit')); + await new Promise(resolve => view.once('messageInserted', resolve)); const stanza = u.toStanza(` @@ -5240,6 +5241,7 @@ _converse.connection._dataRecv(test_utils.createRequest(stanza)); await new Promise(resolve => view.once('messageInserted', resolve)); + expect(view.el.querySelector('.chat-error').textContent.trim()).toBe( "Your message was not delivered because you're not allowed to send messages in this groupchat."); done(); diff --git a/spec/retractions.js b/spec/retractions.js new file mode 100644 index 000000000..75ba5b179 --- /dev/null +++ b/spec/retractions.js @@ -0,0 +1,900 @@ +(function (root, factory) { + define([ + "jasmine", + "mock", + "test-utils" + ], factory); +} (this, function (jasmine, mock, test_utils) { + "use strict"; + const { Strophe, $iq } = converse.env; + const u = converse.env.utils; + + + async function sendAndThenRetractMessage (_converse, view) { + view.model.sendMessage('hello world'); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1); + const msg_obj = view.model.messages.at(0); + const reflection_stanza = u.toStanza(` + + ${msg_obj.get('message')} + + + `); + await view.model.onMessage(reflection_stanza); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); + + const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract')); + retract_button.click(); + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + const sent_stanzas = _converse.connection.sent_stanzas; + return u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + } + + + describe("Message Retractions", function () { + + describe("A groupchat message retraction", function () { + + it("is not applied if it's not from the right author", + mock.initConverse( + ['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + const muc_jid = 'lounge@montague.lit'; + await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + + const received_stanza = u.toStanza(` + + Hello world + + + `); + const view = _converse.api.chatviews.get(muc_jid); + await view.model.onMessage(received_stanza); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + + const retraction_stanza = u.toStanza(` + + + + + + `); + spyOn(view.model, 'handleRetraction').and.callThrough(); + + _converse.connection._dataRecv(test_utils.createRequest(retraction_stanza)); + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1); + expect(view.model.handleRetraction.calls.first().returnValue).toBe(true); + expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.model.messages.length).toBe(2); + expect(view.model.messages.at(1).get('retracted')).toBeTruthy(); + expect(view.model.messages.at(1).get('is_ephemeral')).toBeFalsy(); + expect(view.model.messages.at(1).get('dangling_retraction')).toBe(true); + + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + done(); + })); + + it("can be received before the message it pertains to", + mock.initConverse( + ['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + const date = (new Date()).toISOString(); + const muc_jid = 'lounge@montague.lit'; + await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + + const retraction_stanza = u.toStanza(` + + + + + + `); + const view = _converse.api.chatviews.get(muc_jid); + spyOn(converse.env.log, 'warn'); + spyOn(view.model, 'handleRetraction').and.callThrough(); + _converse.connection._dataRecv(test_utils.createRequest(retraction_stanza)); + + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1); + await u.waitUntil(() => view.model.messages.length === 1); + expect(view.model.handleRetraction.calls.first().returnValue).toBe(true); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeTruthy(); + expect(view.model.messages.at(0).get('dangling_retraction')).toBe(true); + + const received_stanza = u.toStanza(` + + Hello world + + + + + `); + _converse.connection._dataRecv(test_utils.createRequest(received_stanza)); + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2); + + expect(view.el.querySelectorAll('.chat-msg').length).toBe(0); + expect(view.model.messages.length).toBe(1); + + const message = view.model.messages.at(0) + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('dangling_retraction')).toBe(false); + expect(message.get('origin_id')).toBe('origin-id-1'); + expect(message.get(`stanza_id ${muc_jid}`)).toBe('stanza-id-1'); + expect(message.get('time')).toBe(date); + expect(message.get('type')).toBe('groupchat'); + expect(view.model.handleRetraction.calls.all().pop().returnValue).toBe(true); + done(); + })); + }); + + describe("A message retraction", function () { + + it("can be received before the message it pertains to", + mock.initConverse( + ['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + const date = (new Date()).toISOString(); + await test_utils.waitForRoster(_converse, 'current', 1); + await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const view = await test_utils.openChatBoxFor(_converse, contact_jid); + spyOn(view.model, 'handleRetraction').and.callThrough(); + + const retraction_stanza = u.toStanza(` + + + + + + `); + + const promise = new Promise(resolve => _converse.api.listen.on('messageAdded', resolve)); + _converse.connection._dataRecv(test_utils.createRequest(retraction_stanza)); + await u.waitUntil(() => view.model.messages.length === 1); + await promise; + const message = view.model.messages.at(0); + expect(message.get('dangling_retraction')).toBe(true); + expect(message.get('is_ephemeral')).toBe(false); + expect(message.get('retracted')).toBeTruthy(); + expect(view.el.querySelectorAll('.chat-msg').length).toBe(0); + + const stanza = u.toStanza(` + + Hello world + + + + + `); + _converse.connection._dataRecv(test_utils.createRequest(stanza)); + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2); + expect(view.model.messages.length).toBe(1); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('dangling_retraction')).toBe(false); + expect(message.get('origin_id')).toBe('2e972ea0-0050-44b7-a830-f6638a2595b3'); + expect(message.get('time')).toBe(date); + expect(message.get('type')).toBe('chat'); + done(); + })); + }); + + describe("A Received Chat Message", function () { + + it("can be followed up by a retraction", + mock.initConverse( + ['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + await test_utils.waitForRoster(_converse, 'current', 1); + await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const view = await test_utils.openChatBoxFor(_converse, contact_jid); + + let stanza = u.toStanza(` + + 😊 + + + + `); + + _converse.connection._dataRecv(test_utils.createRequest(stanza)); + await u.waitUntil(() => view.model.messages.length === 1); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1); + + stanza = u.toStanza(` + + This message will be retracted + + + + `); + + _converse.connection._dataRecv(test_utils.createRequest(stanza)); + await u.waitUntil(() => view.model.messages.length === 2); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2); + + const retraction_stanza = u.toStanza(` + + + + + + `); + _converse.connection._dataRecv(test_utils.createRequest(retraction_stanza)); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1); + + expect(view.model.messages.length).toBe(2); + + const message = view.model.messages.at(1); + expect(message.get('retracted')).toBeTruthy(); + expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(msg_el.textContent.trim()).toBe('Mercutio has retracted this message'); + expect(u.hasClass('chat-msg--followup', view.el.querySelector('.chat-msg--retracted'))).toBe(true); + done(); + })); + }); + + describe("A Sent Chat Message", function () { + + it("can be retracted by its author", + mock.initConverse( + ['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + await test_utils.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const view = await test_utils.openChatBoxFor(_converse, contact_jid); + + view.model.sendMessage('hello world'); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1); + + const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract')); + retract_button.click(); + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + + const sent_stanzas = _converse.connection.sent_stanzas; + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1); + + const msg_obj = view.model.messages.at(0); + const retraction_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + expect(Strophe.serialize(retraction_stanza)).toBe( + ``+ + ``+ + ``+ + ``+ + ``+ + ``); + + const message = view.model.messages.at(0); + expect(view.model.messages.length).toBe(1); + expect(message.get('retracted')).toBeTruthy(); + expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(el.textContent.trim()).toBe('Romeo Montague has retracted this message'); + done(); + })); + }); + + + describe("A Received Groupchat Message", function () { + + it("can be followed up by a retraction by the author", + mock.initConverse( + ['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + const muc_jid = 'lounge@montague.lit'; + await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + + const received_stanza = u.toStanza(` + + Hello world + + + + `); + const view = _converse.api.chatviews.get(muc_jid); + await view.model.onMessage(received_stanza); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + + const retraction_stanza = u.toStanza(` + + + + + + `); + _converse.connection._dataRecv(test_utils.createRequest(retraction_stanza)); + + // We opportunistically save the message as retracted, even before receiving the retraction message + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeTruthy(); + expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(msg_el.textContent.trim()).toBe('eve has retracted this message'); + expect(msg_el.querySelector('.chat-msg--retracted q')).toBe(null); + done(); + })); + + + it("can be retracted by a moderator, with the IQ response received before the retraction message", + mock.initConverse( + ['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + const muc_jid = 'lounge@montague.lit'; + await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.api.chatviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + + const received_stanza = u.toStanza(` + + Visit this site to get free Bitcoin! + + + `); + await view.model.onMessage(received_stanza); + await u.waitUntil(() => view.model.messages.length === 1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + + const reason = "This content is inappropriate for this forum!" + const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract')); + retract_button.click(); + + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + + const reason_input = document.querySelector('#converse-modals .modal input[name="reason"]'); + reason_input.value = 'This content is inappropriate for this forum!'; + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + const message = view.model.messages.at(0); + const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); + + expect(Strophe.serialize(stanza)).toBe( + ``+ + ``+ + ``+ + ``+ + `This content is inappropriate for this forum!`+ + ``+ + ``+ + ``); + + const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'}); + _converse.connection._dataRecv(test_utils.createRequest(result_iq)); + + // We opportunistically save the message as retracted, even before receiving the retraction message + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1); + + const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo has retracted this message from mallory'); + + const qel = msg_el.querySelector('q'); + expect(qel.textContent.trim()).toBe('This content is inappropriate for this forum!'); + + // The server responds with a retraction message + const retraction = u.toStanza(` + + + + + ${reason} + + + `); + await view.model.onMessage(retraction); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + done(); + })); + + + it("can be retracted by a moderator, with the retraction message received before the IQ response", + mock.initConverse( + ['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + const muc_jid = 'lounge@montague.lit'; + await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.api.chatviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + + const received_stanza = u.toStanza(` + + Visit this site to get free Bitcoin! + + + `); + await view.model.onMessage(received_stanza); + await u.waitUntil(() => view.model.messages.length === 1); + + const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract')); + retract_button.click(); + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + + const reason_input = document.querySelector('#converse-modals .modal input[name="reason"]'); + const reason = "This content is inappropriate for this forum!" + reason_input.value = reason; + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + const message = view.model.messages.at(0); + const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); + // The server responds with a retraction message + const retraction = u.toStanza(` + + + + + ${reason} + + + `); + await view.model.onMessage(retraction); + + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(msg_el.textContent).toBe('romeo has retracted this message from mallory'); + const qel = view.el.querySelector('.chat-msg--retracted .chat-msg__message q'); + expect(qel.textContent).toBe('This content is inappropriate for this forum!'); + + const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'}); + _converse.connection._dataRecv(test_utils.createRequest(result_iq)); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderated_by')).toBe(_converse.bare_jid); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); + done(); + })); + }); + + + describe("A Sent Groupchat Message", function () { + + it("can be retracted by its author", + mock.initConverse( + ['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + const muc_jid = 'lounge@montague.lit'; + await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.api.chatviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + occupant.save('role', 'member'); + const retraction_stanza = await sendAndThenRetractMessage(_converse, view); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1); + + const msg_obj = view.model.messages.at(0); + expect(Strophe.serialize(retraction_stanza)).toBe( + ``+ + ``+ + ``+ + ``+ + ``+ + ``); + + const message = view.model.messages.at(0); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('is_ephemeral')).toBe(false); + + const stanza_id = message.get(`stanza_id ${muc_jid}`); + // The server responds with a retraction message + const reflection = u.toStanza(` + + + + + + + `); + + spyOn(view.model, 'handleRetraction').and.callThrough(); + _converse.connection._dataRecv(test_utils.createRequest(reflection)); + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1); + + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeTruthy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent).toBe('romeo has retracted this message'); + done(); + })); + + it("can be retracted by its author, causing an error message in response", + mock.initConverse( + ['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + const muc_jid = 'lounge@montague.lit'; + await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.api.chatviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + occupant.save('role', 'member'); + const retraction_stanza = await sendAndThenRetractMessage(_converse, view); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1); + + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeTruthy(); + const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('romeo has retracted this message'); + + const message = view.model.messages.at(0); + const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); + // The server responds with an error message + const error = u.toStanza(` + + + + + + + + + + `); + + _converse.connection._dataRecv(test_utils.createRequest(error)); + await u.waitUntil(() => view.el.querySelectorAll('.chat-error').length === 1); + + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 0); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + + expect(view.el.querySelectorAll('.chat-error').length).toBe(1); + const errmsg = view.el.querySelector('.chat-error'); + expect(errmsg.textContent).toBe("Sorry, something went wrong while trying to retract your message."); + done(); + })); + + it("can be retracted by its author, causing an timeout error in response", + mock.initConverse( + ['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + _converse.STANZA_TIMEOUT = 1; + + const muc_jid = 'lounge@montague.lit'; + await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.api.chatviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + occupant.save('role', 'member'); + await sendAndThenRetractMessage(_converse, view); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1); + + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeTruthy(); + const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('romeo has retracted this message'); + + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1); + + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 0); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + + const error_messages = view.el.querySelectorAll('.chat-error'); + expect(error_messages.length).toBe(2); + expect(error_messages[0].textContent).toBe("Sorry, something went wrong while trying to retract your message."); + expect(error_messages[1].textContent).toBe("Timeout Error: No response from server"); + done(); + })); + }); + + + describe("when archived", function () { + + it("may be returned as a tombstone message", + mock.initConverse( + ['discoInitialized'], {}, + async function (done, _converse) { + + await test_utils.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await test_utils.openChatBoxFor(_converse, contact_jid); + await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const queryid = stanza.querySelector('query').getAttribute('queryid'); + const view = _converse.chatboxviews.get(contact_jid); + const first_id = u.getUniqueId(); + + spyOn(view.model, 'handleRetraction').and.callThrough(); + const first_message = u.toStanza(` + + + + + + + 😊 + + + + + `); + _converse.connection._dataRecv(test_utils.createRequest(first_message)); + + const tombstone = u.toStanza(` + + + + + + + + + + + + `); + _converse.connection._dataRecv(test_utils.createRequest(tombstone)); + + const last_id = u.getUniqueId(); + const retraction = u.toStanza(` + + + + + + + + + + + + + `); + _converse.connection._dataRecv(test_utils.createRequest(retraction)); + + const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t(first_id).up() + .c('last').t(last_id).up() + .c('count').t('2'); + _converse.connection._dataRecv(test_utils.createRequest(iq_result)); + + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 3); + + expect(view.model.messages.length).toBe(2); + const message = view.model.messages.at(1); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('is_tombstone')).toBe(true); + expect(view.model.handleRetraction.calls.first().returnValue).toBe(false); + expect(view.model.handleRetraction.calls.all()[1].returnValue).toBe(false); + expect(view.model.handleRetraction.calls.all()[2].returnValue).toBe(true); + expect(view.el.querySelectorAll('.chat-msg').length).toBe(2); + expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('Mercutio has retracted this message'); + expect(u.hasClass('chat-msg--followup', el.parentElement)).toBe(false); + done(); + })); + + it("may be returned as a tombstone groupchat message", + mock.initConverse( + ['discoInitialized'], {}, + async function (done, _converse) { + + const muc_jid = 'lounge@montague.lit'; + await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const queryid = stanza.querySelector('query').getAttribute('queryid'); + + const first_id = u.getUniqueId(); + const tombstone = u.toStanza(` + + + + + + + + + + + + `); + spyOn(view.model, 'handleRetraction').and.callThrough(); + const promise = new Promise(resolve => _converse.api.listen.once('messageAdded', resolve)); + _converse.connection._dataRecv(test_utils.createRequest(tombstone)); + + const last_id = u.getUniqueId(); + const retraction = u.toStanza(` + + + + + + + + + + + + + `); + _converse.connection._dataRecv(test_utils.createRequest(retraction)); + + const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t(first_id).up() + .c('last').t(last_id).up() + .c('count').t('2'); + _converse.connection._dataRecv(test_utils.createRequest(iq_result)); + + await promise; + expect(view.model.messages.length).toBe(1); + let message = view.model.messages.at(0); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('is_tombstone')).toBe(true); + + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2); + expect(view.model.handleRetraction.calls.first().returnValue).toBe(false); + expect(view.model.handleRetraction.calls.all()[1].returnValue).toBe(true); + expect(view.model.messages.length).toBe(1); + message = view.model.messages.at(0); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('is_tombstone')).toBe(true); + expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('eve has retracted this message'); + done(); + })); + + it("may be returned as a tombstone moderated groupchat message", + mock.initConverse( + ['discoInitialized', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + const muc_jid = 'lounge@montague.lit'; + await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const queryid = stanza.querySelector('query').getAttribute('queryid'); + + const first_id = u.getUniqueId(); + const tombstone = u.toStanza(` + + + + + + + + This message contains inappropriate content + + + + + + `); + spyOn(view.model, 'handleModeration').and.callThrough(); + const promise = new Promise(resolve => _converse.api.listen.once('messageAdded', resolve)); + _converse.connection._dataRecv(test_utils.createRequest(tombstone)); + + const last_id = u.getUniqueId(); + const retraction = u.toStanza(` + + + + + + + + + This message contains inappropriate content + + + + + + + `); + _converse.connection._dataRecv(test_utils.createRequest(retraction)); + + const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t(first_id).up() + .c('last').t(last_id).up() + .c('count').t('2'); + _converse.connection._dataRecv(test_utils.createRequest(iq_result)); + + await promise; + expect(view.model.messages.length).toBe(1); + let message = view.model.messages.at(0); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('is_tombstone')).toBe(true); + + await u.waitUntil(() => view.model.handleModeration.calls.count() === 2); + expect(view.model.handleModeration.calls.first().returnValue).toBe(false); + expect(view.model.handleModeration.calls.all()[1].returnValue).toBe(true); + + expect(view.model.messages.length).toBe(1); + message = view.model.messages.at(0); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('is_tombstone')).toBe(true); + expect(message.get('moderation_reason')).toBe("This message contains inappropriate content"); + expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); + + expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('A moderator has retracted this message from eve'); + const qel = view.el.querySelector('.chat-msg--retracted .chat-msg__message q'); + expect(qel.textContent.trim()).toBe('This message contains inappropriate content'); + done(); + })); + }); + }) +})); diff --git a/src/converse-chatview.js b/src/converse-chatview.js index 4dfd236f5..0eca69fc3 100644 --- a/src/converse-chatview.js +++ b/src/converse-chatview.js @@ -65,6 +65,7 @@ converse.plugins.add('converse-chatview', { 'auto_focus': true, 'message_limit': 0, 'show_send_button': false, + 'show_retraction_warning': true, 'show_toolbar': true, 'time_format': 'HH:mm', 'visible_toolbar_buttons': { @@ -226,6 +227,7 @@ converse.plugins.add('converse-chatview', { events: { 'change input.fileupload': 'onFileSelection', 'click .chat-msg__action-edit': 'onMessageEditButtonClicked', + 'click .chat-msg__action-retract': 'onMessageRetractButtonClicked', 'click .chatbox-navback': 'showControlBox', 'click .close-chatbox-button': 'close', 'click .new-msgs-indicator': 'viewUnreadMessages', @@ -622,8 +624,8 @@ converse.plugins.add('converse-chatview', { return this.trigger('messageInserted', view.el); } } - const current_msg_date = dayjs(view.model.get('time')).toDate() || new Date(), - previous_msg_date = this.getLastMessageDate(current_msg_date); + const current_msg_date = dayjs(view.model.get('time')).toDate() || new Date(); + const previous_msg_date = this.getLastMessageDate(current_msg_date); if (previous_msg_date === null) { this.content.insertAdjacentElement('afterbegin', view.el); @@ -649,9 +651,8 @@ converse.plugins.add('converse-chatview', { * followup message or not. * * Followup messages are subsequent ones written by the same - * author with no other conversation elements inbetween and - * posted within 10 minutes of one another. - * + * author with no other conversation elements in between and + * which were posted within 10 minutes of one another. * @private * @method _converse.ChatBoxView#markFollowups * @param { HTMLElement } el - The message element @@ -730,11 +731,9 @@ converse.plugins.add('converse-chatview', { // We already have a view for this message return; } - if (!u.isNewMessage(message) && u.isEmptyMessage(message)) { - // Ignore archived or delayed messages without any text to show. - return message.destroy(); + if (!message.get('dangling_retraction')) { + await this.showMessage(message); } - await this.showMessage(message); /** * Triggered once a message has been added to a chatbox. * @event _converse#messageAdded @@ -914,6 +913,45 @@ converse.plugins.add('converse-chatview', { this.insertIntoTextArea('', true, false); }, + /** + * Retract one of your messages in this chat + * @private + * @method _converse.ChatBoxView#retractOwnMessage + * @param { _converse.Message } message - The message which we're retracting. + */ + retractOwnMessage(message) { + this.model.sendRetractionMessage(message); + message.save({ + 'retracted': (new Date()).toISOString(), + 'retracted_id': message.get('origin_id'), + 'is_ephemeral': true + }); + }, + + async onMessageRetractButtonClicked (ev) { + ev.preventDefault(); + const msg_el = u.ancestor(ev.target, '.message'); + const msgid = msg_el.getAttribute('data-msgid'); + const time = msg_el.getAttribute('data-isodate'); + const message = this.model.messages.findWhere({msgid, time}); + if (message.get('sender') !== 'me') { + return log.error("onMessageEditButtonClicked called for someone else's message!"); + } + const retraction_warning = + __("Be aware that other XMPP/Jabber clients (and servers) may "+ + "not yet support retractions and that this message may not "+ + "be removed everywhere."); + + const messages = [__('Are you sure you want to retract this message?')]; + if (_converse.show_retraction_warning) { + messages[1] = retraction_warning; + } + const result = await _converse.api.confirm(__('Confirm'), messages); + if (result) { + this.retractOwnMessage(message); + } + }, + onMessageEditButtonClicked (ev) { ev.preventDefault(); diff --git a/src/converse-message-view.js b/src/converse-message-view.js index b185870fd..384f7d8fd 100644 --- a/src/converse-message-view.js +++ b/src/converse-message-view.js @@ -21,7 +21,7 @@ import tpl_message_versions_modal from "templates/message_versions_modal.html"; import tpl_spinner from "templates/spinner.html"; import xss from "xss/dist/xss"; -const { dayjs } = converse.env; +const { Strophe, dayjs } = converse.env; const u = converse.env.utils; @@ -140,22 +140,20 @@ converse.plugins.add('converse-message-view', { } else { await this.renderChatMessage(); } - if (is_followup) { - u.addClass('chat-msg--followup', this.el); - } + is_followup && u.addClass('chat-msg--followup', this.el); return this.el; }, async onChanged (item) { // Jot down whether it was edited because the `changed` - // attr gets removed when this.render() gets called further - // down. + // attr gets removed when this.render() gets called further down. const edited = item.changed.edited; if (this.model.changed.progress) { return this.renderFileUploadProgresBar(); } const isValidChange = prop => Object.prototype.hasOwnProperty.call(this.model.changed, prop); - if (['correcting', 'message', 'type', 'upload', 'received', 'editable'].filter(isValidChange).length) { + const props = ['moderated', 'retracted', 'correcting', 'message', 'type', 'upload', 'received', 'editable']; + if (props.filter(isValidChange).length) { await this.debouncedRender(); } if (edited) { @@ -243,19 +241,22 @@ converse.plugins.add('converse-message-view', { const time = dayjs(this.model.get('time')); const role = this.model.vcard ? this.model.vcard.get('role') : null; const roles = role ? role.split(',') : []; + const is_retracted = this.model.get('retracted') || this.model.get('moderated') === 'retracted'; const msg = u.stringToElement(tpl_message( Object.assign( this.model.toJSON(), { - '__': __, - 'is_groupchat_message': this.model.get('type') === 'groupchat', - 'occupant': this.model.occupant, - 'is_me_message': this.model.isMeCommand(), - 'roles': roles, - 'pretty_time': time.format(_converse.time_format), - 'time': time.toISOString(), + __, + is_retracted, 'extra_classes': this.getExtraMessageClasses(), + 'is_groupchat_message': this.model.get('type') === 'groupchat', + 'is_me_message': this.model.isMeCommand(), 'label_show': __('Show more'), + 'occupant': this.model.occupant, + 'pretty_time': time.format(_converse.time_format), + 'retraction_text': is_retracted ? this.getRetractionText() : null, + 'roles': roles, + 'time': time.toISOString(), 'username': this.model.getDisplayName() }) )); @@ -265,11 +266,13 @@ converse.plugins.add('converse-message-view', { msg.querySelector('.chat-msg__media').innerHTML = this.transformOOBURL(url); } - const text = this.model.getMessageText(); - const msg_content = msg.querySelector('.chat-msg__text'); - if (text && text !== url) { - msg_content.innerHTML = await this.transformBodyText(text); - await u.renderImageURLs(_converse, msg_content); + if (!is_retracted) { + const text = this.model.getMessageText(); + const msg_content = msg.querySelector('.chat-msg__text'); + if (text && text !== url) { + msg_content.innerHTML = await this.transformBodyText(text); + await u.renderImageURLs(_converse, msg_content); + } } if (this.model.get('type') !== 'headline') { this.renderAvatar(msg); @@ -292,6 +295,25 @@ converse.plugins.add('converse-message-view', { return this.replaceElement(msg); }, + getRetractionText () { + const username = this.model.getDisplayName(); + let retraction_text = __('A message by %1$s has been retracted', username); + if (this.model.get('type') === 'groupchat') { + const retracted_by_mod = this.model.get('moderated_by'); + if (retracted_by_mod) { + const chatbox = this.model.collection.chatbox; + if (!this.model.mod) { + this.model.mod = + chatbox.occupants.findOccupant({'jid': retracted_by_mod}) || + chatbox.occupants.findOccupant({'nick': Strophe.getResourceFromJid(retracted_by_mod)}); + } + const modname = this.model.mod ? this.model.mod.getDisplayName() : 'A moderator'; + retraction_text = __('%1$s has retracted this message from %2$s', modname , username); + } + } + return retraction_text; + }, + renderErrorMessage () { const msg = u.stringToElement( tpl_info(Object.assign(this.model.toJSON(), { @@ -304,8 +326,8 @@ converse.plugins.add('converse-message-view', { renderChatStateNotification () { let text; - const from = this.model.get('from'), - name = this.model.getDisplayName(); + const from = this.model.get('from'); + const name = this.model.getDisplayName(); if (this.model.get('chat_state') === _converse.COMPOSING) { if (this.model.get('sender') === 'me') { @@ -354,8 +376,10 @@ converse.plugins.add('converse-message-view', { }, getExtraMessageClasses () { - let extra_classes = this.model.get('is_delayed') && 'delayed' || ''; - + const is_retracted = this.model.get('retracted') || this.model.get('moderated') === 'retracted'; + const extra_classes = [ + ...(this.model.get('is_delayed') ? ['delayed'] : []), ...(is_retracted ? ['chat-msg--retracted'] : []) + ]; if (this.model.get('type') === 'groupchat') { if (this.model.occupant) { extra_classes += ` ${this.model.occupant.get('role') || ''} ${this.model.occupant.get('affiliation') || ''}`; diff --git a/src/converse-modal.js b/src/converse-modal.js index 18f30d552..63ab0ab6b 100644 --- a/src/converse-modal.js +++ b/src/converse-modal.js @@ -12,6 +12,7 @@ import converse from "@converse/headless/converse-core"; import { isString } from "lodash"; import tpl_alert from "templates/alert.html"; import tpl_alert_modal from "templates/alert_modal.html"; +import tpl_prompt from "templates/prompt.html"; const { Backbone, sizzle } = converse.env; const u = converse.env.utils; @@ -21,6 +22,7 @@ converse.plugins.add('converse-modal', { initialize () { const { _converse } = this; + const { __ } = _converse; _converse.BootstrapModal = Backbone.VDOMView.extend({ @@ -79,18 +81,69 @@ converse.plugins.add('converse-modal', { } }); - _converse.Alert = _converse.BootstrapModal.extend({ + _converse.Confirm = _converse.BootstrapModal.extend({ + events: { + 'submit .confirm': 'onConfimation' + }, + initialize () { + this.confirmation = u.getResolveablePromise(); + _converse.BootstrapModal.prototype.initialize.apply(this, arguments); + this.listenTo(this.model, 'change', this.render) + this.el.addEventListener('closed.bs.modal', () => this.confirmation.reject(), false); + }, + + toHTML () { + return tpl_prompt(Object.assign({__}, this.model.toJSON())); + }, + + afterRender () { + if (!this.close_handler_registered) { + this.el.addEventListener('closed.bs.modal', () => { + if (!this.confirmation.isResolved) { + this.confirmation.reject() + } + }, false); + this.close_handler_registered = true; + } + }, + + onConfimation (ev) { + ev.preventDefault(); + this.confirmation.resolve(true); + this.modal.hide(); + } + }); + + + _converse.Prompt = _converse.Confirm.extend({ + toHTML () { + return tpl_prompt(Object.assign({__}, this.model.toJSON())); + }, + + onConfimation (ev) { + ev.preventDefault(); + const form_data = new FormData(ev.target); + this.confirmation.resolve(form_data.get('reason')); + this.modal.hide(); + } + }); + + + _converse.Alert = _converse.BootstrapModal.extend({ initialize () { _converse.BootstrapModal.prototype.initialize.apply(this, arguments); this.listenTo(this.model, 'change', this.render) }, toHTML () { - return tpl_alert_modal(this.model.toJSON()); + return tpl_alert_modal( + Object.assign({__}, this.model.toJSON())); } }); + + /************************ BEGIN Event Listeners ************************/ _converse.api.listen.on('afterTearDown', () => { if (!_converse.chatboxviews) { return; @@ -104,40 +157,112 @@ converse.plugins.add('converse-modal', { /************************ BEGIN API ************************/ // We extend the default converse.js API to add methods specific to MUC chat rooms. - let alert; + let alert, prompt, confirm; Object.assign(_converse.api, { + + /** + * Show a confirm modal to the user. + * @method _converse.api.confirm + * @param { String } title - The header text for the confirmation dialog + * @param { (String[]|String) } messages - The text to show to the user + * @returns { Promise } A promise which resolves with true or false + */ + async confirm (title, messages=[]) { + if (isString(messages)) { + messages = [messages]; + } + if (confirm === undefined) { + const model = new Backbone.Model({ + 'title': title, + 'messages': messages, + 'type': 'confirm' + }) + confirm = new _converse.Confirm({model}); + } else { + confirm.model.set({ + 'title': title, + 'messages': messages, + 'type': 'confirm' + }); + } + confirm.show(); + try { + return await confirm.confirmation; + } catch (e) { + return false; + } + }, + + /** + * Show a prompt modal to the user. + * @method _converse.api.prompt + * @param { String } title - The header text for the prompt + * @param { (String[]|String) } messages - The prompt text to show to the user + * @param { String } placeholder - The placeholder text for the prompt input + * @returns { Promise } A promise which resolves with the text provided by the + * user or `false` if the user canceled the prompt. + */ + async prompt (title, messages=[], placeholder='') { + if (isString(messages)) { + messages = [messages]; + } + if (prompt === undefined) { + const model = new Backbone.Model({ + 'title': title, + 'messages': messages, + 'placeholder': placeholder, + 'type': 'prompt' + }) + prompt = new _converse.Prompt({model}); + } else { + prompt.model.set({ + 'title': title, + 'messages': messages, + 'type': 'prompt' + }); + } + prompt.show(); + try { + return await prompt.confirmation; + } catch (e) { + return false; + } + }, + /** * Show an alert modal to the user. * @method _converse.api.alert * @param { ('info'|'warn'|'error') } type - The type of alert. - * @returns { String } title - The header text for the alert. - * @returns { (String[]|String) } messages - The alert text to show to the user. + * @param { String } title - The header text for the alert. + * @param { (String[]|String) } messages - The alert text to show to the user. */ alert (type, title, messages) { if (isString(messages)) { messages = [messages]; } + let level; if (type === 'error') { - type = 'alert-danger'; + level = 'alert-danger'; } else if (type === 'info') { - type = 'alert-info'; + level = 'alert-info'; } else if (type === 'warn') { - type = 'alert-warning'; + level = 'alert-warning'; } if (alert === undefined) { const model = new Backbone.Model({ 'title': title, 'messages': messages, - 'type': type + 'level': level, + 'type': 'alert' }) - alert = new _converse.Alert({'model': model}); + alert = new _converse.Alert({model}); } else { alert.model.set({ 'title': title, 'messages': messages, - 'type': type + 'level': level }); } alert.show(); diff --git a/src/converse-muc-views.js b/src/converse-muc-views.js index 1d2a61f33..b4dd17f9e 100644 --- a/src/converse-muc-views.js +++ b/src/converse-muc-views.js @@ -41,7 +41,6 @@ import tpl_rooms_results from "templates/rooms_results.html"; import tpl_spinner from "templates/spinner.html"; import xss from "xss/dist/xss"; - const { Backbone, Strophe, sizzle, _, $iq, $pres } = converse.env; const u = converse.env.utils; @@ -108,6 +107,7 @@ converse.plugins.add('converse-muc-views', { 'auto_list_rooms': false, 'cache_muc_messages': true, 'locked_muc_nickname': false, + 'show_retraction_warning': true, 'muc_disable_slash_commands': false, 'muc_show_join_leave': true, 'muc_show_join_leave_status': true, @@ -630,6 +630,7 @@ converse.plugins.add('converse-muc-views', { events: { 'change input.fileupload': 'onFileSelection', 'click .chat-msg__action-edit': 'onMessageEditButtonClicked', + 'click .chat-msg__action-retract': 'onMessageRetractButtonClicked', 'click .chatbox-navback': 'showControlBox', 'click .close-chatbox-button': 'close', 'click .configure-chatroom-button': 'getAndRenderConfigurationForm', @@ -724,8 +725,7 @@ converse.plugins.add('converse-muc-views', { }, renderChatArea () { - /* Render the UI container in which groupchat messages will appear. - */ + // Render the UI container in which groupchat messages will appear. if (this.el.querySelector('.chat-area') === null) { const container_el = this.el.querySelector('.chatroom-body'); container_el.insertAdjacentHTML( @@ -811,6 +811,101 @@ converse.plugins.add('converse-muc-views', { return _converse.ChatBoxView.prototype.onKeyUp.call(this, ev); }, + async onMessageRetractButtonClicked (ev) { + ev.preventDefault(); + const msg_el = u.ancestor(ev.target, '.message'); + const msgid = msg_el.getAttribute('data-msgid'); + const time = msg_el.getAttribute('data-isodate'); + const message = this.model.messages.findWhere({msgid, time}); + const retraction_warning = + __("Be aware that other XMPP/Jabber clients (and servers) may "+ + "not yet support retractions and that this message may not "+ + "be removed everywhere."); + + if (message.get('sender') === 'me') { + const messages = [__('Are you sure you want to retract this message?')]; + if (_converse.show_retraction_warning) { + messages[1] = retraction_warning; + } + const result = await _converse.api.confirm(__('Confirm'), messages); + if (result) { + this.retractOwnMessage(message); + } + } else { + let messages = [ + __('You are about to retract this message.'), + __('You may optionally include a message, explaining the reason for the retraction.') + ]; + if (_converse.show_retraction_warning) { + messages = [messages[0], retraction_warning, messages[1]] + } + const reason = await _converse.api.prompt( + __('Message Retraction'), + messages, + __('Optional reason') + ); + if (reason !== false) { + this.retractOtherMessage(message, reason); + } + } + }, + + /** + * Retract one of your messages in this groupchat. + * @private + * @method _converse.ChatRoomView#retractOwnMessage + * @param { _converse.Message } message - The message which we're retracting. + */ + retractOwnMessage(message) { + this.model.sendRetractionMessage(message) + .catch(e => { + message.save({ + 'retracted': undefined, + 'retracted_id': undefined + }); + const errmsg = __('Sorry, something went wrong while trying to retract your message.'); + if (u.isErrorStanza(e)) { + this.showErrorMessage(errmsg); + } else { + this.showErrorMessage(errmsg); + this.showErrorMessage(e.message); + } + log.error(e); + }); + message.save({ + 'retracted': (new Date()).toISOString(), + 'retracted_id': message.get('origin_id') + }); + }, + + /** + * Retract someone else's message in this groupchat. + * @private + * @method _converse.ChatRoomView#retractOtherMessage + * @param { _converse.Message } message - The message which we're retracting. + * @param { string } [reason] - The reason for retracting the message. + */ + async retractOtherMessage (message, reason) { + const result = await this.model.sendRetractionIQ(message, reason); + if (result === null) { + const err_msg = __(`A timeout occurred while trying to retract the message`); + _converse.api.alert('error', __('Error'), err_msg); + _converse.log(err_msg, Strophe.LogLevel.WARN); + } else if (u.isErrorStanza(result)) { + const err_msg = __(`Sorry, you're not allowed to retract this message.`); + _converse.api.alert('error', __('Error'), err_msg); + _converse.log(err_msg, Strophe.LogLevel.WARN); + _converse.log(result, Strophe.LogLevel.WARN); + } else { + message.save({ + 'moderated': 'retracted', + 'moderated_by': _converse.bare_jid, + 'moderated_id': message.get('msgid'), + 'moderation_reason': reason + }); + } + }, + showModeratorToolsModal (affiliation) { if (!this.verifyRoles(['moderator'])) { return; @@ -2193,7 +2288,7 @@ converse.plugins.add('converse-muc-views', { * @namespace _converse.api.roomviews * @memberOf _converse.api */ - 'roomviews': { + roomviews: { /** * Retrieves a groupchat (aka chatroom) view. The chat should already be open. * diff --git a/src/converse-notification.js b/src/converse-notification.js index 0d0d41926..129baabf5 100644 --- a/src/converse-notification.js +++ b/src/converse-notification.js @@ -121,6 +121,7 @@ converse.plugins.add('converse-notification', { _converse.areDesktopNotificationsEnabled = function () { return _converse.supports_html5_notification && + _converse.show_desktop_notifications && Notification.permission === "granted"; }; diff --git a/src/headless/converse-chat.js b/src/headless/converse-chat.js index a82dfab0d..876213b73 100644 --- a/src/headless/converse-chat.js +++ b/src/headless/converse-chat.js @@ -1,10 +1,10 @@ -import "./utils/stanza"; -import { get, isObject, isString, propertyOf } from "lodash"; +import { get, isObject, isString, pick } from "lodash"; import converse from "./converse-core"; import filesize from "filesize"; import log from "./log"; +import stanza_utils from "./utils/stanza"; -const { $msg, Backbone, Strophe, dayjs, sizzle, utils } = converse.env; +const { $msg, Backbone, Strophe, sizzle, utils } = converse.env; const u = converse.env.utils; @@ -21,7 +21,7 @@ converse.plugins.add('converse-chat', { * * NB: These plugins need to have already been loaded via require.js. */ - dependencies: ["stanza-utils", "converse-chatboxes", "converse-disco"], + dependencies: ["converse-chatboxes", "converse-disco"], initialize () { /* The initialize function gets called as soon as the plugin is @@ -29,7 +29,6 @@ converse.plugins.add('converse-chat', { */ const { _converse } = this; const { __ } = _converse; - const { stanza_utils } = _converse; // Configuration values for this plugin // ==================================== @@ -75,7 +74,7 @@ converse.plugins.add('converse-chat', { return { 'msgid': u.getUniqueId(), 'time': (new Date()).toISOString(), - 'ephemeral': false + 'is_ephemeral': false }; }, @@ -86,17 +85,36 @@ converse.plugins.add('converse-chat', { ModelWithContact.prototype.initialize.apply(this, arguments); this.setRosterContact(Strophe.getBareJidFromJid(this.get('from'))); } - if (this.get('file')) { this.on('change:put', this.uploadFile, this); } - if (this.isEphemeral()) { - window.setTimeout(this.safeDestroy.bind(this), 10000); - } + this.setTimerForEphemeralMessage(); await _converse.api.trigger('messageInitialized', this, {'Synchronous': true}); this.initialized.resolve(); }, + /** + * Sets an auto-destruct timer for this message, if it's is_ephemeral. + * @private + * @method _converse.Message#setTimerForEphemeralMessage + * @returns { Boolean } - Indicates whether the message is + * ephemeral or not, and therefore whether the timer was set or not. + */ + setTimerForEphemeralMessage () { + const setTimer = () => { + this.ephemeral_timer = window.setTimeout(this.safeDestroy.bind(this), 10000); + } + if (this.isEphemeral()) { + setTimer(); + return true; + } else { + this.on('change:is_ephemeral', + () => this.isEphemeral() ? setTimer() : clearTimeout(this.ephemeral_timer) + ); + return false; + } + }, + safeDestroy () { try { this.destroy() @@ -110,7 +128,7 @@ converse.plugins.add('converse-chat', { }, isEphemeral () { - return this.isOnlyChatStateNotification() || this.get('ephemeral'); + return this.get('is_ephemeral') || u.isOnlyChatStateNotification(this); }, getDisplayName () { @@ -171,7 +189,7 @@ converse.plugins.add('converse-chat', { return this.save({ 'type': 'error', 'message': __("Sorry, could not determine upload URL."), - 'ephemeral': true + 'is_ephemeral': true }); } const slot = stanza.querySelector('slot'); @@ -184,7 +202,7 @@ converse.plugins.add('converse-chat', { return this.save({ 'type': 'error', 'message': __("Sorry, could not determine file upload URL."), - 'ephemeral': true + 'is_ephemeral': true }); } }, @@ -223,7 +241,7 @@ converse.plugins.add('converse-chat', { 'type': 'error', 'upload': _converse.FAILURE, 'message': message, - 'ephemeral': true + 'is_ephemeral': true }); }; xhr.open('PUT', this.get('put'), true); @@ -338,17 +356,21 @@ converse.plugins.add('converse-chat', { const message = await this.getDuplicateMessage(stanza); if (message) { this.updateMessage(message, original_stanza); - } else { - if ( - !this.handleReceipt (stanza, from_jid) && - !this.handleChatMarker(stanza, from_jid) + } else if ( + !this.handleReceipt (stanza, from_jid) && + !this.handleChatMarker(stanza, from_jid) + ) { + const attrs = await this.getMessageAttributesFromStanza(stanza, original_stanza); + if (this.handleRetraction(attrs)) { + return; + } + this.setEditable(attrs, attrs.time, stanza); + if (attrs['chat_state'] || + attrs['retracted'] || // Retraction received *before* the message + !u.isEmptyMessage(attrs) ) { - const attrs = await this.getMessageAttributesFromStanza(stanza, original_stanza); - this.setEditable(attrs, attrs.time, stanza); - if (attrs['chat_state'] || !u.isEmptyMessage(attrs)) { - const msg = this.correctMessage(attrs) || this.messages.create(attrs); - this.incrementUnreadMsgCounter(msg); - } + const msg = this.handleCorrection(attrs) || this.messages.create(attrs); + this.incrementUnreadMsgCounter(msg); } } }, @@ -517,28 +539,90 @@ converse.plugins.add('converse-chat', { return true; }, - retractMessage (attrs) { - if (!attrs.moderated !== 'retracted' && !attrs.retracted) { - return; + isSameUser (jid1, jid2) { + return u.isSameBareJID(jid1, jid2); + }, + + /** + * Looks whether we already have a retraction for this + * incoming message. If so, it's considered "dangling" because it + * probably hasn't been applied to anything yet, given that the + * relevant message is only coming in now. + * @private + * @method _converse.ChatBox#findDanglingRetraction + * @param { object } attrs - Attributes representing a received + * message, as returned by {@link stanza_utils.getMessageAttributesFromStanza} + * @returns { _converse.Message } + */ + findDanglingRetraction (attrs) { + if (!attrs.origin_id || !this.messages.length) { + return null; } - const message = this.messages.findWhere({'msgid': attrs.replaced_id, 'from': attrs.from}); - if (!message) { - return; + // Only look for dangling retractions if there are newer + // messages than this one, since retractions come after. + if (this.messages.last().get('time') > attrs.time) { + // Search from latest backwards + const messages = Array.from(this.messages.models); + messages.reverse(); + return messages.find( + ({attributes}) => + attributes.retracted_id === attrs.origin_id && + attributes.from === attrs.from && + !attributes.moderated_by + ); } }, /** - * Determine whether the passed in message attributes represent a + * Handles message retraction based on the passed in attributes. + * @private + * @method _converse.ChatBox#handleRetraction + * @param { object } attrs - Attributes representing a received + * message, as returned by {@link stanza_utils.getMessageAttributesFromStanza} + * @returns { Boolean } Returns `true` or `false` depending on + * whether a message was retracted or not. + */ + handleRetraction (attrs) { + const RETRACTION_ATTRIBUTES = ['retracted', 'retracted_id']; + if (attrs.retracted) { + if (attrs.is_tombstone) { + return false; + } + const message = this.messages.findWhere({'origin_id': attrs.retracted_id, 'from': attrs.from}); + if (!message) { + attrs['dangling_retraction'] = true; + this.messages.create(attrs); + return true; + } + message.save(pick(attrs, RETRACTION_ATTRIBUTES)); + return true; + } else { + // Check if we have dangling retraction + const message = this.findDanglingRetraction(attrs); + if (message) { + const retraction_attrs = pick(message.attributes, RETRACTION_ATTRIBUTES); + const new_attrs = Object.assign({'dangling_retraction': false}, attrs, retraction_attrs); + delete new_attrs['id']; // Delete id, otherwise a new cache entry gets created + message.save(new_attrs); + return true; + } + } + return false; + }, + + /** + * Determines whether the passed in message attributes represent a * message which corrects a previously received message, or an * older message which has already been corrected. * In both cases, update the corrected message accordingly. * @private - * @method _converse.ChatBox#correctMessage + * @method _converse.ChatBox#handleCorrection * @param { object } attrs - Attributes representing a received - * message, as returned by - * {@link _converse.ChatBox.getMessageAttributesFromStanza} + * message, as returned by {@link stanza_utils.getMessageAttributesFromStanza} + * @returns { _converse.Message|undefined } Returns the corrected + * message or `undefined` if not applicable. */ - correctMessage (attrs) { + handleCorrection (attrs) { if (!attrs.replaced_id || !attrs.from) { return; } @@ -604,6 +688,30 @@ converse.plugins.add('converse-chat', { }); }, + /** + * Sends a message stanza to retract a message in this chat + * @private + * @method _converse.ChatBox#sendRetractionMessage + * @param { _converse.Message } message - The message which we're retracting. + */ + sendRetractionMessage (message) { + const origin_id = message.get('origin_id'); + if (!origin_id) { + throw new Error("Can't retract message without a XEP-0359 Origin ID"); + } + const msg = $msg({ + 'id': u.getUniqueId(), + 'to': this.get('jid'), + 'type': "chat" + }) + .c('store', {xmlns: Strophe.NS.HINTS}).up() + .c("apply-to", { + 'id': origin_id, + 'xmlns': Strophe.NS.FASTEN + }).c('retract', {xmlns: Strophe.NS.RETRACT}) + return _converse.connection.send(msg); + }, + sendMarker(to_jid, id, type) { const stanza = $msg({ 'from': _converse.connection.jid, @@ -849,7 +957,7 @@ converse.plugins.add('converse-chat', { this.messages.create({ 'message': __("Sorry, looks like file upload is not supported by your server."), 'type': 'error', - 'ephemeral': true + 'is_ephemeral': true }); return; } @@ -861,7 +969,7 @@ converse.plugins.add('converse-chat', { this.messages.create({ 'message': __("Sorry, looks like file upload is not supported by your server."), 'type': 'error', - 'ephemeral': true + 'is_ephemeral': true }); return; } @@ -871,7 +979,7 @@ converse.plugins.add('converse-chat', { 'message': __('The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.', file.name, filesize(max_file_size)), 'type': 'error', - 'ephemeral': true + 'is_ephemeral': true }); } else { const attrs = Object.assign( @@ -890,46 +998,19 @@ converse.plugins.add('converse-chat', { }, /** - * Parses a passed in message stanza and returns an object - * of attributes. + * Parses a passed in message stanza and returns an object of attributes. * @private * @method _converse.ChatBox#getMessageAttributesFromStanza * @param { XMLElement } stanza - The message stanza - * @param { XMLElement } delay - The node from the stanza, if there was one. * @param { XMLElement } original_stanza - The original stanza, that contains the * message stanza, if it was contained, otherwise it's the message stanza itself. * @returns { Object } */ - async getMessageAttributesFromStanza (stanza, original_stanza) { - const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); - const text = stanza_utils.getMessageBody(stanza) || undefined; - const chat_state = stanza.getElementsByTagName(_converse.COMPOSING).length && _converse.COMPOSING || - stanza.getElementsByTagName(_converse.PAUSED).length && _converse.PAUSED || - stanza.getElementsByTagName(_converse.INACTIVE).length && _converse.INACTIVE || - stanza.getElementsByTagName(_converse.ACTIVE).length && _converse.ACTIVE || - stanza.getElementsByTagName(_converse.GONE).length && _converse.GONE; - - return Object.assign( - { - 'chat_state': chat_state, - 'is_archived': stanza_utils.isArchived(original_stanza), - 'is_delayed': !!delay, - 'is_single_emoji': text ? await u.isSingleEmoji(text) : false, - 'message': text, - 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), - 'references': stanza_utils.getReferences(stanza), - 'subject': propertyOf(stanza.querySelector('subject'))('textContent'), - 'thread': propertyOf(stanza.querySelector('thread'))('textContent'), - 'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(), - 'type': stanza.getAttribute('type') - }, - stanza_utils.getStanzaIDs(original_stanza), - stanza_utils.getSenderAttributes(stanza, this), - stanza_utils.getOutOfBandAttributes(stanza), - stanza_utils.getMessageFasteningAttributes(stanza), - stanza_utils.getSpoilerAttributes(stanza), - stanza_utils.getCorrectionAttributes(stanza, original_stanza) - ); + getMessageAttributesFromStanza (stanza, original_stanza) { + // XXX: Eventually we want to get rid of this pass-through + // method but currently we still need it because converse-omemo + // overrides it. + return stanza_utils.getMessageAttributesFromStanza(stanza, original_stanza, this, _converse); }, maybeShow () { diff --git a/src/headless/converse-core.js b/src/headless/converse-core.js index 6b3b87a92..eed3cc6aa 100644 --- a/src/headless/converse-core.js +++ b/src/headless/converse-core.js @@ -37,16 +37,19 @@ Strophe.addNamespace('CARBONS', 'urn:xmpp:carbons:2'); Strophe.addNamespace('CHATSTATES', 'http://jabber.org/protocol/chatstates'); Strophe.addNamespace('CSI', 'urn:xmpp:csi:0'); Strophe.addNamespace('DELAY', 'urn:xmpp:delay'); +Strophe.addNamespace('FASTEN', 'urn:xmpp:fasten:0'); Strophe.addNamespace('FORWARD', 'urn:xmpp:forward:0'); Strophe.addNamespace('HINTS', 'urn:xmpp:hints'); Strophe.addNamespace('HTTPUPLOAD', 'urn:xmpp:http:upload:0'); Strophe.addNamespace('IDLE', 'urn:xmpp:idle:1'); Strophe.addNamespace('MAM', 'urn:xmpp:mam:2'); +Strophe.addNamespace('MODERATE', 'urn:xmpp:message-moderate:0'); Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick'); Strophe.addNamespace('OMEMO', 'eu.siacs.conversations.axolotl'); Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob'); Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub'); Strophe.addNamespace('REGISTER', 'jabber:iq:register'); +Strophe.addNamespace('RETRACT', 'urn:xmpp:message-retract:0'); Strophe.addNamespace('ROSTERX', 'http://jabber.org/protocol/rosterx'); Strophe.addNamespace('RSM', 'http://jabber.org/protocol/rsm'); Strophe.addNamespace('SID', 'urn:xmpp:sid:0'); @@ -92,8 +95,7 @@ const CORE_PLUGINS = [ 'converse-rsm', 'converse-smacks', 'converse-status', - 'converse-vcard', - 'stanza-utils' + 'converse-vcard' ]; @@ -103,7 +105,7 @@ const CORE_PLUGINS = [ * @global * @namespace _converse */ -// XXX: Strictly speaking _converse is not a global, but we need to set it as +// Strictly speaking _converse is not a global, but we need to set it as // such to get JSDoc to create the correct document site strucure. const _converse = { 'templates': {}, @@ -142,6 +144,10 @@ class TimeoutError extends Error {} _converse.TimeoutError = TimeoutError; +class IllegalMessage extends Error {} +_converse.IllegalMessage = IllegalMessage; + + // Make converse pluggable pluggable.enable(_converse, '_converse', 'pluggable'); @@ -187,7 +193,7 @@ _converse.LOGOUT = 'logout'; _converse.OPENED = 'opened'; _converse.PREBIND = 'prebind'; -_converse.IQ_TIMEOUT = 20000; +_converse.STANZA_TIMEOUT = 10000; _converse.CONNECTION_STATUS = { 0: 'ERROR', @@ -1694,7 +1700,7 @@ _converse.api = { * or is rejected when we receive an `error` stanza. */ sendIQ (stanza, timeout, reject=true) { - timeout = timeout || _converse.IQ_TIMEOUT; + timeout = timeout || _converse.STANZA_TIMEOUT; let promise; if (reject) { promise = new Promise((resolve, reject) => _converse.connection.sendIQ(stanza, resolve, reject, timeout)); diff --git a/src/headless/converse-mam.js b/src/headless/converse-mam.js index 8c41351af..ec0df9480 100644 --- a/src/headless/converse-mam.js +++ b/src/headless/converse-mam.js @@ -55,7 +55,6 @@ converse.plugins.add('converse-mam', { }); const MAMEnabledChat = { - /** * Fetches messages that might have been archived *after* * the last archived message in our local cache. diff --git a/src/headless/converse-muc.js b/src/headless/converse-muc.js index 6a28e90de..639ea740b 100644 --- a/src/headless/converse-muc.js +++ b/src/headless/converse-muc.js @@ -11,7 +11,7 @@ */ import "./converse-disco"; import "./converse-emoji"; -import { clone, get, intersection, invoke, isElement, isObject, isString, uniq, zipObject } from "lodash"; +import { clone, get, intersection, invoke, isElement, isObject, isString, pick, uniq, zipObject } from "lodash"; import converse from "./converse-core"; import log from "./log"; import muc_utils from "./utils/muc"; @@ -252,9 +252,7 @@ converse.plugins.add('converse-muc', { if (this.get('file')) { this.on('change:put', this.uploadFile, this); } - if (this.isEphemeral()) { - window.setTimeout(this.safeDestroy.bind(this), 10000); - } else { + if (!this.setTimerForEphemeralMessage()) { this.setOccupant(); this.setVCard(); } @@ -510,9 +508,8 @@ converse.plugins.add('converse-muc', { }, removeHandlers () { - /* Remove the presence and message handlers that were - * registered for this groupchat. - */ + // Remove the presence and message handlers that were + // registered for this groupchat. if (this.message_handler) { if (_converse.connection) { _converse.connection.deleteHandler(this.message_handler); @@ -571,15 +568,96 @@ converse.plugins.add('converse-muc', { return this; }, + /** + * Sends a message stanza to the XMPP server and expects a reflection + * or error message within a specific timeout period. + * @private + * @method _converse.ChatRoom#sendTimedMessage + * @param { _converse.Message|XMLElement } message + * @returns { Promise|Promise<_converse.TimeoutError> } Returns a promise + * which resolves with the reflected message stanza or rejects + * with an error stanza or with a {@link _converse.TimeoutError}. + */ + sendTimedMessage (el) { + if (typeof(el.tree) === "function") { + el = el.tree(); + } + let id = el.getAttribute('id'); + if (!id) { // inject id if not found + id = this.getUniqueId("sendIQ"); + el.setAttribute("id", id); + } + const promise = u.getResolveablePromise(); + const timeoutHandler = _converse.connection.addTimedHandler( + _converse.STANZA_TIMEOUT, + () => { + _converse.connection.deleteHandler(handler); + promise.reject(new _converse.TimeoutError("Timeout Error: No response from server")); + return false; + } + ); + const handler = _converse.connection.addHandler(stanza => { + timeoutHandler && _converse.connection.deleteTimedHandler(timeoutHandler); + if (stanza.getAttribute('type') === 'groupchat') { + promise.resolve(stanza); + } else { + promise.reject(stanza); + } + }, null, 'message', ['error', 'groupchat'], id); + _converse.api.send(el) + return promise; + }, + + /** + * Sends a message stanza to retract a message in this groupchat. + * @private + * @method _converse.ChatRoom#sendRetractionMessage + * @param { _converse.Message } message - The message which we're retracting. + */ + sendRetractionMessage (message) { + const origin_id = message.get('origin_id'); + if (!origin_id) { + throw new Error("Can't retract message without a XEP-0359 Origin ID"); + } + const msg = $msg({ + 'id': u.getUniqueId(), + 'to': this.get('jid'), + 'type': "groupchat" + }) + .c('store', {xmlns: Strophe.NS.HINTS}).up() + .c("apply-to", { + 'id': origin_id, + 'xmlns': Strophe.NS.FASTEN + }).c('retract', {xmlns: Strophe.NS.RETRACT}); + return this.sendTimedMessage(msg); + }, + + /** + * Sends an IQ stanza to the XMPP server to retract a message in this groupchat. + * @private + * @method _converse.ChatRoom#sendRetractionIQ + * @param { _converse.Message } message - The message which we're retracting. + * @param { string } [reason] - The reason for retracting the message. + */ + sendRetractionIQ (message, reason) { + const iq = $iq({'to': this.get('jid'), 'type': "set"}) + .c("apply-to", { + 'id': message.get(`stanza_id ${this.get('jid')}`), + 'xmlns': Strophe.NS.FASTEN + }).c('moderate', {xmlns: Strophe.NS.MODERATE}) + .c('retract', {xmlns: Strophe.NS.RETRACT}).up() + .c('reason').t(reason); + return _converse.api.sendIQ(iq, null, false); + }, + /** * Sends an IQ stanza to the XMPP server to destroy this groupchat. Not * to be confused with the {@link _converse.ChatRoom#destroy} * method, which simply removes the room from the local browser storage cache. * @private * @method _converse.ChatRoom#sendDestroyIQ - * @param { string } [reason] - The reason for destroying the groupchat - * @param { string } [new_jid] - The JID of the new groupchat which - * replaces this one. + * @param { string } [reason] - The reason for destroying the groupchat. + * @param { string } [new_jid] - The JID of the new groupchat which replaces this one. */ sendDestroyIQ (reason, new_jid) { const destroy = $build("destroy"); @@ -1320,6 +1398,38 @@ converse.plugins.add('converse-muc', { } }, + /** + * Given two JIDs, which can be either user JIDs or MUC occupant JIDs, + * determine whether they belong to the same user. + * @private + * @method _converse.ChatRoom#isSameUser + * @param { String } jid1 + * @param { String } jid2 + * @returns { Boolean } + */ + isSameUser (jid1, jid2) { + const bare_jid1 = Strophe.getBareJidFromJid(jid1); + const bare_jid2 = Strophe.getBareJidFromJid(jid2); + const resource1 = Strophe.getResourceFromJid(jid1); + const resource2 = Strophe.getResourceFromJid(jid2); + if (u.isSameBareJID(jid1, jid2)) { + if (bare_jid1 === this.get('jid')) { + // MUC JIDs + return resource1 === resource2; + } else { + return true; + } + } else { + const occupant1 = (bare_jid1 === this.get('jid')) ? + this.occupants.findOccupant({'nick': resource1}) : + this.occupants.findOccupant({'jid': bare_jid1}); + + const occupant2 = (bare_jid2 === this.get('jid')) ? + this.occupants.findOccupant({'nick': resource2}) : + this.occupants.findOccupant({'jid': bare_jid2}); + return occupant1 === occupant2; + } + }, /** * Handle a subject change and return `true` if so. @@ -1460,14 +1570,89 @@ converse.plugins.add('converse-muc', { return _converse.ChatBox.prototype.shouldShowErrorMessage.call(this, stanza); }, - getErrorMessage (stanza) { - if (sizzle(`forbidden[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) { - return __("Your message was not delivered because you're not allowed to send messages in this groupchat."); - } else if (sizzle(`not-acceptable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) { - return __("Your message was not delivered because you're not present in the groupchat."); - } else { - return _converse.ChatBox.prototype.getErrorMessage.call(this, stanza); + /** + * Looks whether we already have a moderation message for this + * incoming message. If so, it's considered "dangling" because + * it probably hasn't been applied to anything yet, given that + * the relevant message is only coming in now. + * @private + * @method _converse.ChatRoom#findDanglingModeration + * @param { object } attrs - Attributes representing a received + * message, as returned by {@link stanza_utils.getMessageAttributesFromStanza} + * @returns { _converse.ChatRoomMessage } + */ + findDanglingModeration (attrs) { + if (!this.messages.length) { + return null; } + // Only look for dangling moderation if there are newer + // messages than this one, since moderation come after. + if (this.messages.last().get('time') > attrs.time) { + // Search from latest backwards + const messages = Array.from(this.messages.models); + const stanza_id = attrs[`stanza_id ${this.get('jid')}`]; + if (!stanza_id) { + return null; + } + messages.reverse(); + return messages.find( + ({attributes}) => + attributes.moderation === 'retraction' && + attributes.moderated_id === stanza_id && + attributes.moderated_by + ); + } + }, + + /** + * Handles message moderation based on the passed in attributes. + * @private + * @method _converse.ChatRoom#handleModeration + * @param { object } attrs - Attributes representing a received + * message, as returned by {@link stanza_utils.getMessageAttributesFromStanza} + * @returns { Boolean } Returns `true` or `false` depending on + * whether a message was moderated or not. + */ + handleModeration (attrs) { + const MODERATION_ATTRIBUTES = [ + 'moderated', + 'moderated_by', + 'moderated_id', + 'moderation_reason' + ]; + if (attrs.moderated === 'retracted') { + const query = {}; + const key = `stanza_id ${this.get('jid')}`; + query[key] = attrs.moderated_id; + const message = this.messages.findWhere(query); + if (!message) { + attrs['dangling_moderation'] = true; + this.messages.create(attrs); + return true; + } + message.save(pick(attrs, MODERATION_ATTRIBUTES)); + return true; + } else { + // Check if we have dangling moderation message + const message = this.findDanglingModeration(attrs); + if (message) { + const moderation_attrs = pick(message.attributes, MODERATION_ATTRIBUTES); + const new_attrs = Object.assign({'dangling_moderation': false}, attrs, moderation_attrs); + delete new_attrs['id']; // Delete id, otherwise a new cache entry gets created + message.save(new_attrs); + return true; + } + } + return false; + }, + + createMessageObject (attrs) { + return new Promise((success, reject) => { + this.messages.create( + attrs, + { success, 'error': (m, e) => reject(e) } + ) + }); }, /** @@ -1477,21 +1662,17 @@ converse.plugins.add('converse-muc', { * @param { XMLElement } stanza - The message stanza. */ async onMessage (stanza) { - const original_stanza = stanza; - const bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length; - if (bare_forward) { + if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length) { return log.warn('onMessage: Ignoring unencapsulated forwarded groupchat message'); } - const is_carbon = u.isCarbonMessage(stanza); - if (is_carbon) { - // XEP-280: groupchat messages SHOULD NOT be carbon copied, so we're discarding it. + if (u.isCarbonMessage(stanza)) { return log.warn( 'onMessage: Ignoring XEP-0280 "groupchat" message carbon, '+ 'according to the XEP groupchat messages SHOULD NOT be carbon copied' ); } - const is_mam = u.isMAMMessage(stanza); - if (is_mam) { + const original_stanza = stanza; + if (u.isMAMMessage(stanza)) { if (original_stanza.getAttribute('from') === this.get('jid')) { const selector = `[xmlns="${Strophe.NS.MAM}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`; stanza = sizzle(selector, stanza).pop(); @@ -1499,7 +1680,6 @@ converse.plugins.add('converse-muc', { return log.warn(`onMessage: Ignoring alleged MAM groupchat message from ${stanza.getAttribute('from')}`); } } - this.createInfoMessages(stanza); this.fetchFeaturesIfConfigurationChanged(stanza); @@ -1510,20 +1690,18 @@ converse.plugins.add('converse-muc', { if (message || stanza_utils.isReceipt(stanza) || stanza_utils.isChatMarker(stanza)) { return _converse.api.trigger('message', {'stanza': original_stanza}); } - const attrs = await this.getMessageAttributesFromStanza(stanza, original_stanza); - this.setEditable(attrs, attrs.time); - if (attrs.nick && - !this.subjectChangeHandled(attrs) && - !this.ignorableCSN(attrs) && - (attrs['chat_state'] || !u.isEmptyMessage(attrs))) { - const msg = this.correctMessage(attrs) || - await new Promise((success, reject) => { - this.messages.create( - attrs, - { success, 'erorr': (m, e) => reject(e) } - ) - }); + const attrs = await this.getMessageAttributesFromStanza(stanza, original_stanza); + if (this.handleRetraction(attrs) || + this.handleModeration(attrs) || + this.subjectChangeHandled(attrs) || + this.ignorableCSN(attrs)) { + return _converse.api.trigger('message', {'stanza': original_stanza}); + } + this.setEditable(attrs, attrs.time); + + if (attrs.nick && (attrs.is_tombstone || u.isNewMessage(attrs) || !u.isEmptyMessage(attrs))) { + const msg = this.handleCorrection(attrs) || await this.createMessageObject(attrs); this.incrementUnreadMsgCounter(msg); } _converse.api.trigger('message', {'stanza': original_stanza, 'chatbox': this}); @@ -1539,7 +1717,7 @@ converse.plugins.add('converse-muc', { const attrs = { 'type': 'error', 'message': text, - 'ephemeral': true + 'is_ephemeral': true } this.messages.create(attrs); } @@ -2121,7 +2299,7 @@ converse.plugins.add('converse-muc', { * @namespace _converse.api.rooms * @memberOf _converse.api */ - 'rooms': { + rooms: { /** * Creates a new MUC chatroom (aka groupchat) * diff --git a/src/headless/utils/stanza.js b/src/headless/utils/stanza.js index 856bb2b0a..905e9eabe 100644 --- a/src/headless/utils/stanza.js +++ b/src/headless/utils/stanza.js @@ -1,5 +1,7 @@ import * as strophe from 'strophe.js/src/core'; import { get, propertyOf } from "lodash"; +import dayjs from 'dayjs'; +import log from '@converse/headless/log'; import sizzle from 'sizzle'; import u from '@converse/headless/utils/core'; @@ -38,19 +40,19 @@ const stanza_utils = { * Extract the XEP-0359 stanza IDs from the passed in stanza * and return a map containing them. * @private - * @method _converse.stanza_utils#getStanzaIDs + * @method stanza_utils#getStanzaIDs * @param { XMLElement } stanza - The message stanza * @returns { Object } */ - getStanzaIDs (stanza) { + getStanzaIDs (stanza, original_stanza) { const attrs = {}; const stanza_ids = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza); if (stanza_ids.length) { stanza_ids.forEach(s => (attrs[`stanza_id ${s.getAttribute('by')}`] = s.getAttribute('id'))); } - const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop(); + const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(); if (result) { - const by_jid = stanza.getAttribute('from'); + const by_jid = original_stanza.getAttribute('from'); attrs[`stanza_id ${by_jid}`] = result.getAttribute('id'); } @@ -64,35 +66,91 @@ const stanza_utils = { return attrs; }, - /** - * Parses a passed in message stanza and returns an object of known attributes related to - * XEP-0422 Message Fastening. - * @private - * @method _converse.stanza_utils#getMessageFasteningAttributes + /** @method stanza_utils#getModerationAttributes * @param { XMLElement } stanza - The message stanza + * @param { XMLElement } original_stanza - The original stanza, that contains the + * message stanza, if it was contained, otherwise it's the message stanza itself. + * @param { _converse.ChatRoom } room - The MUC in which the moderation stanza is received. * @returns { Object } */ - getMessageFasteningAttributes (stanza) { - const substanza = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop(); - if (substanza === null) { - return {}; - } - const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, substanza).pop(); - if (moderated) { - const retracted = !!sizzle(`retract[xmlns="${Strophe.NS.RETRACT}"]`, moderated).length; - return { - 'moderated': retracted ? 'retracted' : 'unknown', - 'moderated_by': moderated.get('by'), - 'moderated_reason': get(moderated.querySelector('reason'), 'textContent') + getModerationAttributes (stanza, original_stanza, room) { + const fastening = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop(); + if (fastening) { + const applies_to_id = fastening.getAttribute('id'); + const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, fastening).pop(); + if (moderated) { + const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT}"]`, moderated).pop(); + if (retracted) { + const from = stanza.getAttribute('from'); + if (from !== room.get('jid')) { + log.warn("getModerationAttributes: ignore moderation stanza that's not from the MUC!"); + log.error(original_stanza); + return {}; + } + return { + 'moderated': 'retracted', + 'moderated_by': moderated.getAttribute('by'), + 'moderated_id': applies_to_id, + 'moderation_reason': get(moderated.querySelector('reason'), 'textContent') + } + } + } + } else { + const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE}"]`, stanza).pop(); + if (tombstone) { + const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, tombstone).pop(); + if (retracted) { + return { + 'is_tombstone': true, + 'retracted': tombstone.getAttribute('stamp'), + 'moderated_by': tombstone.getAttribute('by'), + 'moderation_reason': get(tombstone.querySelector('reason'), 'textContent') + + } + } } } + return {}; + }, + + + /** + * @method stanza_utils#getRetractionAttributes + * @param { XMLElement } stanza - The message stanza + * @param { XMLElement } original_stanza - The original stanza, that contains the + * message stanza, if it was contained, otherwise it's the message stanza itself. + * @returns { Object } + */ + getRetractionAttributes (stanza, original_stanza) { + const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop(); + if (fastening) { + const applies_to_id = fastening.getAttribute('id'); + const retracted = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, fastening).pop(); + if (retracted) { + const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); + const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(); + return { + 'retracted': time, + 'retracted_id': applies_to_id + } + } + } else { + const tombstone = sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop(); + if (tombstone) { + return { + 'retracted': tombstone.getAttribute('stamp'), + 'is_tombstone': true + } + } + } + return {}; }, getReferences (stanza) { const text = propertyOf(stanza.querySelector('body'))('textContent'); return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => { - const begin = ref.getAttribute('begin'), - end = ref.getAttribute('end'); + const begin = ref.getAttribute('begin'); + const end = ref.getAttribute('end'); return { 'begin': begin, 'end': end, @@ -105,8 +163,7 @@ const stanza_utils = { getSenderAttributes (stanza, chatbox, _converse) { - const type = stanza.getAttribute('type'); - if (type === 'groupchat') { + if (u.isChatRoom(chatbox)) { const from = stanza.getAttribute('from'); const nick = Strophe.unescapeNode(Strophe.getResourceFromJid(from)); return { @@ -152,20 +209,107 @@ const stanza_utils = { return {}; }, - getCorrectionAttributes (stanza) { + getCorrectionAttributes (stanza, original_stanza) { const el = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop(); if (el) { const replaced_id = el.getAttribute('id'); const msgid = replaced_id; if (replaced_id) { + const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); + const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(); return { msgid, replaced_id, - 'edited': new Date().toISOString() + 'edited': time } } } return {}; + }, + + getErrorMessage (stanza, is_muc, _converse) { + const { __ } = _converse; + if (is_muc) { + if (sizzle(`forbidden[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) { + return __("Your message was not delivered because you're not allowed to send messages in this groupchat."); + } else if (sizzle(`not-acceptable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) { + return __("Your message was not delivered because you're not present in the groupchat."); + } + } + const error = stanza.querySelector('error'); + return propertyOf(error.querySelector('text'))('textContent') || + __('Sorry, an error occurred:') + ' ' + error.innerHTML; + }, + + /** + * Given a message stanza, return the text contained in its body. + * @private + * @method stanza_utils#getMessageBody + * @param { XMLElement } stanza + * @param { Boolean } is_muc + * @param { _converse } _converse + */ + getMessageBody (stanza, is_muc, _converse) { + const type = stanza.getAttribute('type'); + if (type === 'error') { + return stanza_utils.getErrorMessage(stanza, is_muc, _converse); + } else { + const body = stanza.querySelector('body'); + if (body) { + return body.textContent.trim(); + } + } + }, + + getChatState (stanza) { + return stanza.getElementsByTagName('composing').length && 'composing' || + stanza.getElementsByTagName('paused').length && 'paused' || + stanza.getElementsByTagName('inactive').length && 'inactive' || + stanza.getElementsByTagName('active').length && 'active' || + stanza.getElementsByTagName('gone').length && 'gone'; + }, + + /** + * Parses a passed in message stanza and returns an object of attributes. + * @private + * @method stanza_utils#getMessageAttributesFromStanza + * @param { XMLElement } stanza - The message stanza + * @param { XMLElement } original_stanza - The original stanza, that contains the + * message stanza, if it was contained, otherwise it's the message stanza itself. + * @param { _converse.ChatBox|_converse.ChatRoom } chatbox + * @param { _converse } _converse + * @returns { Object } + */ + async getMessageAttributesFromStanza (stanza, original_stanza, chatbox, _converse) { + const is_muc = u.isChatRoom(chatbox); + let attrs = Object.assign( + stanza_utils.getStanzaIDs(stanza, original_stanza), + stanza_utils.getRetractionAttributes(stanza, original_stanza), + is_muc ? stanza_utils.getModerationAttributes(stanza, original_stanza, chatbox) : {}, + ); + const text = stanza_utils.getMessageBody(stanza, is_muc, _converse) || undefined; + const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); + attrs = Object.assign( + { + 'chat_state': stanza_utils.getChatState(stanza), + 'is_archived': stanza_utils.isArchived(original_stanza), + 'is_delayed': !!delay, + 'is_single_emoji': text ? await u.isOnlyEmojis(text) : false, + 'message': text, + 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), + 'references': stanza_utils.getReferences(stanza), + 'subject': propertyOf(stanza.querySelector('subject'))('textContent'), + 'thread': propertyOf(stanza.querySelector('thread'))('textContent'), + 'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(), + 'type': stanza.getAttribute('type') + }, + attrs, + stanza_utils.getSenderAttributes(stanza, chatbox, _converse), + stanza_utils.getOutOfBandAttributes(stanza), + stanza_utils.getSpoilerAttributes(stanza), + stanza_utils.getCorrectionAttributes(stanza, original_stanza) + ) + return attrs; } } diff --git a/src/templates/alert_modal.html b/src/templates/alert_modal.html index 3a8380ad7..00377c4d3 100644 --- a/src/templates/alert_modal.html +++ b/src/templates/alert_modal.html @@ -1,7 +1,7 @@ diff --git a/src/templates/prompt.html b/src/templates/prompt.html new file mode 100644 index 000000000..51b00692a --- /dev/null +++ b/src/templates/prompt.html @@ -0,0 +1,30 @@ + diff --git a/src/utils/html.js b/src/utils/html.js index 18250ffd9..3176177df 100644 --- a/src/utils/html.js +++ b/src/utils/html.js @@ -293,6 +293,8 @@ u.ancestor = function (el, selector) { * Return the element's siblings until one matches the selector. * @private * @method u#nextUntil + * @param { HTMLElement } el + * @param { String } selector */ u.nextUntil = function (el, selector) { const matches = []; diff --git a/tests/runner.js b/tests/runner.js index f04bb7518..62b32806a 100644 --- a/tests/runner.js +++ b/tests/runner.js @@ -55,6 +55,7 @@ var specs = [ "spec/user-details-modal", "spec/messages", "spec/muc_messages", + "spec/retractions", "spec/muc", "spec/modtools", "spec/room_registration", diff --git a/webpack.html b/webpack.html index 3b7a00596..be3d81b61 100644 --- a/webpack.html +++ b/webpack.html @@ -18,17 +18,13 @@ }); converse.initialize({ auto_away: 300, - auto_login: true, auto_register_muc_nickname: true, - bosh_service_url: 'http://chat.example.org:5380/http-bind/', debug: true, enable_smacks: true, i18n: 'en', - jid: 'klaus.dresner@chat.example.org', message_archiving: 'always', muc_domain: 'conference.chat.example.org', muc_respect_autojoin: true, - password: 'secret', view_mode: 'fullscreen', websocket_url: 'ws://chat.example.org:5380/xmpp-websocket', whitelisted_plugins: ['converse-debug'],