From da5ca0b58537acf4722d460193cbe106f8957130 Mon Sep 17 00:00:00 2001 From: Christoph Scholz Date: Sat, 3 Nov 2018 14:37:57 +0100 Subject: [PATCH] implement XEP-0184: Message Delivery Receipts --- CHANGES.md | 1 + css/converse.css | 3 + dist/converse.js | 75 ++++++++++++++++++++--- sass/_messages.scss | 4 ++ sass/_variables.scss | 1 + spec/http-file-upload.js | 2 + spec/messages.js | 96 ++++++++++++++++++++++++++++++ spec/omemo.js | 1 + src/converse-message-view.js | 2 +- src/converse-omemo.js | 1 + src/headless/converse-chatboxes.js | 38 +++++++++++- src/headless/converse-muc.js | 14 ++++- src/templates/message.html | 1 + 13 files changed, 226 insertions(+), 13 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index ac010a892..78dde0f71 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,7 @@ - Error `FATAL: TypeError: Cannot read property 'extend' of undefined` when using `embedded` view mode. - Default paths in converse-notifications.js are now relative - Add a button to regenerate OMEMO keys +- #141 XEP-0184: Message Delivery Receipts - #1188 Feature request: drag and drop file to HTTP Upload - #1268 Switch from SASS variables to CSS custom properties - #1278 Replace the default avatar with a SVG version diff --git a/css/converse.css b/css/converse.css index d342d1982..2745ea5f4 100644 --- a/css/converse.css +++ b/css/converse.css @@ -9303,6 +9303,7 @@ readers do not read off random characters that represent icons */ --text-color: #666; --text-color-lighten-15-percent: #8c8c8c; --message-text-color: #555; + --message-receipt-color: #3AA569; --save-button-color: #3AA569; --chat-textarea-color: #666; --chat-textarea-height: 60px; @@ -11796,6 +11797,8 @@ body.reset { display: none; } #conversejs .message.chat-msg.chat-msg--followup .chat-msg__content { margin-left: 2.75rem; } + #conversejs .message.chat-msg .chat-msg__receipt { + color: var(--message-receipt-color); } #conversejs .chatroom-body .message.onload { animation: colorchange-chatmessage-muc 1s; diff --git a/dist/converse.js b/dist/converse.js index 0a2ba64b6..fff1db82b 100644 --- a/dist/converse.js +++ b/dist/converse.js @@ -61682,7 +61682,7 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins return this.renderFileUploadProgresBar(); } - if (_.filter(['correcting', 'message', 'type', 'upload'], prop => Object.prototype.hasOwnProperty.call(this.model.changed, prop)).length) { + if (_.filter(['correcting', 'message', 'type', 'upload', 'received'], prop => Object.prototype.hasOwnProperty.call(this.model.changed, prop)).length) { await this.render(); } @@ -65704,7 +65704,9 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins 'to': this.get('jid'), 'type': this.get('message_type'), 'id': message.get('msgid') - }).c('body').t(body).up() // An encrypted header is added to the message for + }).c('body').t(body).up().c('request', { + 'xmlns': Strophe.NS.RECEIPTS + }).up() // An encrypted header is added to the message for // each device that is supposed to receive it. // These headers simply contain the key that the // payload message is encrypted with, @@ -70630,6 +70632,7 @@ const _converse$env = _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].env _ = _converse$env._; const u = _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].env.utils; Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0'); +Strophe.addNamespace('RECEIPTS', 'urn:xmpp:receipts'); Strophe.addNamespace('REFERENCE', 'urn:xmpp:reference:0'); _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].plugins.add('converse-chatboxes', { dependencies: ["converse-roster", "converse-vcard"], @@ -70940,6 +70943,31 @@ _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].plugins.add('converse-cha return false; }, + handleReceipt(stanza) { + const to_bare_jid = Strophe.getBareJidFromJid(stanza.getAttribute('to')); + + if (to_bare_jid === _converse.bare_jid) { + const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop(); + + if (receipt) { + const msgid = receipt && receipt.getAttribute('id'), + message = msgid && this.messages.findWhere({ + msgid + }); + + if (message && !message.get('received')) { + message.save({ + 'received': moment().format() + }); + } + + return true; + } + } + + return false; + }, + createMessageStanza(message) { /* Given a _converse.Message Backbone.Model, return the XML * stanza that represents it. @@ -70954,6 +70982,8 @@ _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].plugins.add('converse-cha 'id': message.get('edited') && _converse.connection.getUniqueId() || message.get('msgid') }).c('body').t(message.get('message')).up().c(_converse.ACTIVE, { 'xmlns': Strophe.NS.CHATSTATES + }).up().c('request', { + 'xmlns': Strophe.NS.RECEIPTS }).up(); if (message.get('is_spoiler')) { @@ -71344,6 +71374,19 @@ _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].plugins.add('converse-cha } }, + sendReceiptStanza(to_jid, id) { + const receipt_stanza = $msg({ + 'from': _converse.connection.jid, + 'id': _converse.connection.getUniqueId(), + 'to': to_jid + }).c('received', { + 'xmlns': Strophe.NS.RECEIPTS, + 'id': id + }).up(); + + _converse.api.send(receipt_stanza); + }, + onMessage(stanza) { /* Handler method for all incoming single-user chat "message" * stanzas. @@ -71387,6 +71430,12 @@ _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].plugins.add('converse-cha to_jid = stanza.getAttribute('to'); } + const requests_receipt = !_.isUndefined(sizzle(`request[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop()); + + if (requests_receipt) { + this.sendReceiptStanza(from_jid, stanza.getAttribute('id')); + } + const from_bare_jid = Strophe.getBareJidFromJid(from_jid), from_resource = Strophe.getResourceFromJid(from_jid), is_me = from_bare_jid === _converse.bare_jid; @@ -71410,7 +71459,7 @@ _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].plugins.add('converse-cha const has_body = sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`).length > 0; const chatbox = this.getChatBox(contact_jid, attrs, has_body); - if (chatbox && !chatbox.handleMessageCorrection(stanza)) { + if (chatbox && !chatbox.handleMessageCorrection(stanza) && !chatbox.handleReceipt(stanza)) { const msgid = stanza.getAttribute('id'), message = msgid && chatbox.messages.findWhere({ msgid @@ -76065,15 +76114,23 @@ _converse_core__WEBPACK_IMPORTED_MODULE_6__["default"].plugins.add('converse-muc return data; }, - isDuplicate(message, original_stanza) { + isDuplicate(message) { const msgid = message.getAttribute('id'), jid = message.getAttribute('from'); if (msgid) { - return this.messages.where({ + const msg = this.messages.findWhere({ 'msgid': msgid, 'from': jid - }).length; + }); + + if (msg && msg.get('sender') === 'me' && !msg.get('received')) { + msg.save({ + 'received': moment().format() + }); + } + + return msg; } return false; @@ -76106,7 +76163,7 @@ _converse_core__WEBPACK_IMPORTED_MODULE_6__["default"].plugins.add('converse-muc stanza = forwarded.querySelector('message'); } - if (this.isDuplicate(stanza, original_stanza)) { + if (this.isDuplicate(stanza)) { return; } @@ -102447,6 +102504,10 @@ __p += '\n \n '; if (!o.is_me_message) { ; __p += '
'; } ; +__p += '\n '; + if (o.received) { ; +__p += '   '; + } ; __p += '\n '; if (o.edited) { ; __p += ' `+ `${message}`+ ``+ + ``+ ``+ `${message}`+ ``+ @@ -459,6 +460,7 @@ `xmlns="jabber:client">`+ `${message}`+ ``+ + ``+ ``+ `${message}`+ ``+ diff --git a/spec/messages.js b/spec/messages.js index e096e07ab..5a18f6411 100644 --- a/spec/messages.js +++ b/spec/messages.js @@ -77,6 +77,7 @@ `xmlns="jabber:client">`+ `But soft, what light through yonder window breaks?`+ ``+ + ``+ ``+ ``); expect(view.model.messages.models.length).toBe(1); @@ -181,6 +182,7 @@ `xmlns="jabber:client">`+ `But soft, what light through yonder window breaks?`+ ``+ + ``+ ``+ ``); expect(view.model.messages.models.length).toBe(1); @@ -1200,6 +1202,64 @@ done(); })); + it("received may emit a message delivery receipt", + mock.initConverseWithPromises( + null, ['rosterGroupsFetched', 'chatBoxesFetched'], {}, + function (done, _converse) { + test_utils.createContacts(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost'; + const msg_id = u.getUniqueId(); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(function (stanza) { + sent_stanzas.push(stanza); + }); + const msg = $msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': msg_id, + }).c('body').t('Message!').up() + .c('request', {'xmlns': Strophe.NS.RECEIPTS}).tree(); + _converse.chatboxes.onMessage(msg); + const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, sent_stanzas[0].tree()).pop(); + expect(receipt.outerHTML).toBe(``); + done(); + })); + + it("delivery can be acknowledged by a receipt", + mock.initConverseWithPromises( + null, ['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + test_utils.createContacts(_converse, 'current', 1); + _converse.emit('rosterContactsFetched'); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost'; + await test_utils.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + const textarea = view.el.querySelector('textarea.chat-textarea'); + textarea.value = 'But soft, what light through yonder airlock breaks?'; + view.keyPressed({ + target: textarea, + preventDefault: _.noop, + keyCode: 13 // Enter + }); + await test_utils.waitUntil(() => _converse.api.chats.get().length); + const chatbox = _converse.chatboxes.get(contact_jid); + expect(chatbox).toBeDefined(); + await new Promise((resolve, reject) => view.once('messageInserted', resolve)); + const msg_obj = chatbox.messages.models[0]; + const msg_id = msg_obj.get('msgid'); + const msg = $msg({ + 'from': contact_jid, + 'to': _converse.connection.jid, + 'id': u.getUniqueId(), + }).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree(); + _converse.chatboxes.onMessage(msg); + await new Promise((resolve, reject) => view.model.messages.once('rendered', resolve)); + expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(1); + done(); + })); + describe("when received from someone else", function () { @@ -2010,6 +2070,7 @@ `xmlns="jabber:client">`+ `But soft, what light through yonder window breaks?`+ ``+ + ``+ ``+ ``); @@ -2056,6 +2117,38 @@ done(); })); + it("delivery can be acknowledged by a receipt", + mock.initConverseWithPromises( + null, ['rosterGroupsFetched'], {}, + async function (done, _converse) { + + test_utils.createContacts(_converse, 'current'); + await test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'dummy'); + const view = _converse.chatboxviews.get('lounge@localhost'); + const textarea = view.el.querySelector('textarea.chat-textarea'); + textarea.value = 'But soft, what light through yonder airlock breaks?'; + view.keyPressed({ + target: textarea, + preventDefault: _.noop, + keyCode: 13 // Enter + }); + await new Promise((resolve, reject) => view.once('messageInserted', resolve)); + const msg_obj = view.model.messages.at(0); + const msg_id = msg_obj.get('msgid'); + const from = msg_obj.get('from'); + const body = msg_obj.get('message'); + const msg = $msg({ + 'from': from, + 'id': msg_id, + 'to': 'dummy@localhost', + 'type': 'groupchat', + }).c('body').t(body).up().tree(); + view.model.onMessage(msg); + await new Promise((resolve, reject) => view.model.messages.once('rendered', resolve)); + expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(1); + done(); + })); + describe("when received", function () { it("highlights all users mentioned via XEP-0372 references", @@ -2201,6 +2294,7 @@ `xmlns="jabber:client">`+ `hello z3r0 gibson mr.robot, how are you?`+ ``+ + ``+ ``+ ``+ ``+ @@ -2226,6 +2320,7 @@ `xmlns="jabber:client">`+ `hello z3r0 gibson sw0rdf1sh, how are you?`+ ``+ + ``+ ``+ ``+ ``+ @@ -2274,6 +2369,7 @@ `xmlns="jabber:client">`+ `hello z3r0 gibson mr.robot, how are you?`+ ``+ + ``+ ``+ ``+ ``+ diff --git a/spec/omemo.js b/spec/omemo.js index d24a60684..95f64208b 100644 --- a/spec/omemo.js +++ b/spec/omemo.js @@ -172,6 +172,7 @@ `to="max.frankfurter@localhost" `+ `type="chat" xmlns="jabber:client">`+ `This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo`+ + ``+ ``+ `
`+ `YzFwaDNSNzNYNw==`+ diff --git a/src/converse-message-view.js b/src/converse-message-view.js index 16396852d..8cd942206 100644 --- a/src/converse-message-view.js +++ b/src/converse-message-view.js @@ -86,7 +86,7 @@ converse.plugins.add('converse-message-view', { if (this.model.changed.progress) { return this.renderFileUploadProgresBar(); } - if (_.filter(['correcting', 'message', 'type', 'upload'], + if (_.filter(['correcting', 'message', 'type', 'upload', 'received'], prop => Object.prototype.hasOwnProperty.call(this.model.changed, prop)).length) { await this.render(); } diff --git a/src/converse-omemo.js b/src/converse-omemo.js index c8efc7b76..560319c41 100644 --- a/src/converse-omemo.js +++ b/src/converse-omemo.js @@ -394,6 +394,7 @@ converse.plugins.add('converse-omemo', { 'type': this.get('message_type'), 'id': message.get('msgid') }).c('body').t(body).up() + .c('request', {'xmlns': Strophe.NS.RECEIPTS}).up() // An encrypted header is added to the message for // each device that is supposed to receive it. // These headers simply contain the key that the diff --git a/src/headless/converse-chatboxes.js b/src/headless/converse-chatboxes.js index 563190f76..cf69d0db7 100644 --- a/src/headless/converse-chatboxes.js +++ b/src/headless/converse-chatboxes.js @@ -13,6 +13,7 @@ const { $msg, Backbone, Promise, Strophe, b64_sha1, moment, sizzle, utils, _ } = const u = converse.env.utils; Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0'); +Strophe.addNamespace('RECEIPTS', 'urn:xmpp:receipts'); Strophe.addNamespace('REFERENCE', 'urn:xmpp:reference:0'); @@ -297,6 +298,24 @@ converse.plugins.add('converse-chatboxes', { return false; }, + handleReceipt (stanza) { + const to_bare_jid = Strophe.getBareJidFromJid(stanza.getAttribute('to')); + if (to_bare_jid === _converse.bare_jid) { + const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop(); + if (receipt) { + const msgid = receipt && receipt.getAttribute('id'), + message = msgid && this.messages.findWhere({msgid}); + if (message && !message.get('received')) { + message.save({ + 'received': moment().format() + }); + } + return true; + } + } + return false; + }, + createMessageStanza (message) { /* Given a _converse.Message Backbone.Model, return the XML * stanza that represents it. @@ -310,7 +329,8 @@ converse.plugins.add('converse-chatboxes', { 'type': this.get('message_type'), 'id': message.get('edited') && _converse.connection.getUniqueId() || message.get('msgid'), }).c('body').t(message.get('message')).up() - .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).up(); + .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).up() + .c('request', {'xmlns': Strophe.NS.RECEIPTS}).up(); if (message.get('is_spoiler')) { if (message.get('spoiler_hint')) { @@ -663,6 +683,15 @@ converse.plugins.add('converse-chatboxes', { } }, + sendReceiptStanza (to_jid, id) { + const receipt_stanza = $msg({ + 'from': _converse.connection.jid, + 'id': _converse.connection.getUniqueId(), + 'to': to_jid, + }).c('received', {'xmlns': Strophe.NS.RECEIPTS, 'id': id}).up(); + _converse.api.send(receipt_stanza); + }, + onMessage (stanza) { /* Handler method for all incoming single-user chat "message" * stanzas. @@ -709,6 +738,11 @@ converse.plugins.add('converse-chatboxes', { to_jid = stanza.getAttribute('to'); } + const requests_receipt = !_.isUndefined(sizzle(`request[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop()); + if (requests_receipt) { + this.sendReceiptStanza(from_jid, stanza.getAttribute('id')); + } + const from_bare_jid = Strophe.getBareJidFromJid(from_jid), from_resource = Strophe.getResourceFromJid(from_jid), is_me = from_bare_jid === _converse.bare_jid; @@ -732,7 +766,7 @@ converse.plugins.add('converse-chatboxes', { // Get chat box, but only create a new one when the message has a body. const has_body = sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`).length > 0; const chatbox = this.getChatBox(contact_jid, attrs, has_body); - if (chatbox && !chatbox.handleMessageCorrection(stanza)) { + if (chatbox && !chatbox.handleMessageCorrection(stanza) && !chatbox.handleReceipt(stanza)) { const msgid = stanza.getAttribute('id'), message = msgid && chatbox.messages.findWhere({msgid}); if (!message) { diff --git a/src/headless/converse-muc.js b/src/headless/converse-muc.js index d9b54539a..a136ce6f7 100644 --- a/src/headless/converse-muc.js +++ b/src/headless/converse-muc.js @@ -913,13 +913,21 @@ converse.plugins.add('converse-muc', { return data; }, - isDuplicate (message, original_stanza) { + isDuplicate (message) { const msgid = message.getAttribute('id'), jid = message.getAttribute('from'); + if (msgid) { - return this.messages.where({'msgid': msgid, 'from': jid}).length; + const msg = this.messages.findWhere({'msgid': msgid, 'from': jid}); + if (msg && msg.get('sender') === 'me' && !msg.get('received')) { + msg.save({ + 'received': moment().format() + }); + } + return msg; } return false; + }, fetchFeaturesIfConfigurationChanged (stanza) { @@ -949,7 +957,7 @@ converse.plugins.add('converse-muc', { if (!_.isNull(forwarded)) { stanza = forwarded.querySelector('message'); } - if (this.isDuplicate(stanza, original_stanza)) { + if (this.isDuplicate(stanza)) { return; } const jid = stanza.getAttribute('from'), diff --git a/src/templates/message.html b/src/templates/message.html index 3539ccece..a54c1ec02 100644 --- a/src/templates/message.html +++ b/src/templates/message.html @@ -12,6 +12,7 @@ {[ if (o.is_encrypted) { ]}{[ } ]} {[ if (!o.is_me_message) { ]}
{[ } ]} + {[ if (o.received) { ]}   {[ } ]} {[ if (o.edited) { ]} {[ } ]} {[ if (!o.is_me_message) { ]}
{[ } ]} {[ if (o.is_spoiler) { ]}