Browse Source

implement XEP-0184: Message Delivery Receipts

Christoph Scholz 6 years ago
parent
commit
da5ca0b585

+ 1 - 0
CHANGES.md

@@ -5,6 +5,7 @@
 - Error `FATAL: TypeError: Cannot read property 'extend' of undefined` when using `embedded` view mode.
 - Error `FATAL: TypeError: Cannot read property 'extend' of undefined` when using `embedded` view mode.
 - Default paths in converse-notifications.js are now relative
 - Default paths in converse-notifications.js are now relative
 - Add a button to regenerate OMEMO keys
 - Add a button to regenerate OMEMO keys
+- #141 XEP-0184: Message Delivery Receipts
 - #1188 Feature request: drag and drop file to HTTP Upload
 - #1188 Feature request: drag and drop file to HTTP Upload
 - #1268 Switch from SASS variables to CSS custom properties
 - #1268 Switch from SASS variables to CSS custom properties
 - #1278 Replace the default avatar with a SVG version
 - #1278 Replace the default avatar with a SVG version

+ 3 - 0
css/converse.css

@@ -9303,6 +9303,7 @@ readers do not read off random characters that represent icons */
   --text-color: #666;
   --text-color: #666;
   --text-color-lighten-15-percent: #8c8c8c;
   --text-color-lighten-15-percent: #8c8c8c;
   --message-text-color: #555;
   --message-text-color: #555;
+  --message-receipt-color: #3AA569;
   --save-button-color: #3AA569;
   --save-button-color: #3AA569;
   --chat-textarea-color: #666;
   --chat-textarea-color: #666;
   --chat-textarea-height: 60px;
   --chat-textarea-height: 60px;
@@ -11796,6 +11797,8 @@ body.reset {
     display: none; }
     display: none; }
   #conversejs .message.chat-msg.chat-msg--followup .chat-msg__content {
   #conversejs .message.chat-msg.chat-msg--followup .chat-msg__content {
     margin-left: 2.75rem; }
     margin-left: 2.75rem; }
+  #conversejs .message.chat-msg .chat-msg__receipt {
+    color: var(--message-receipt-color); }
 
 
 #conversejs .chatroom-body .message.onload {
 #conversejs .chatroom-body .message.onload {
   animation: colorchange-chatmessage-muc 1s;
   animation: colorchange-chatmessage-muc 1s;

+ 68 - 7
dist/converse.js

@@ -61682,7 +61682,7 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins
           return this.renderFileUploadProgresBar();
           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();
           await this.render();
         }
         }
 
 
@@ -65704,7 +65704,9 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins
           'to': this.get('jid'),
           'to': this.get('jid'),
           'type': this.get('message_type'),
           'type': this.get('message_type'),
           'id': message.get('msgid')
           '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.
         // each device that is supposed to receive it.
         // These headers simply contain the key that the
         // These headers simply contain the key that the
         // payload message is encrypted with,
         // payload message is encrypted with,
@@ -70630,6 +70632,7 @@ const _converse$env = _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].env
       _ = _converse$env._;
       _ = _converse$env._;
 const u = _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].env.utils;
 const u = _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].env.utils;
 Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
 Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
+Strophe.addNamespace('RECEIPTS', 'urn:xmpp:receipts');
 Strophe.addNamespace('REFERENCE', 'urn:xmpp:reference:0');
 Strophe.addNamespace('REFERENCE', 'urn:xmpp:reference:0');
 _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].plugins.add('converse-chatboxes', {
 _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].plugins.add('converse-chatboxes', {
   dependencies: ["converse-roster", "converse-vcard"],
   dependencies: ["converse-roster", "converse-vcard"],
@@ -70940,6 +70943,31 @@ _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].plugins.add('converse-cha
         return false;
         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) {
       createMessageStanza(message) {
         /* Given a _converse.Message Backbone.Model, return the XML
         /* Given a _converse.Message Backbone.Model, return the XML
          * stanza that represents it.
          * 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')
           'id': message.get('edited') && _converse.connection.getUniqueId() || message.get('msgid')
         }).c('body').t(message.get('message')).up().c(_converse.ACTIVE, {
         }).c('body').t(message.get('message')).up().c(_converse.ACTIVE, {
           'xmlns': Strophe.NS.CHATSTATES
           'xmlns': Strophe.NS.CHATSTATES
+        }).up().c('request', {
+          'xmlns': Strophe.NS.RECEIPTS
         }).up();
         }).up();
 
 
         if (message.get('is_spoiler')) {
         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) {
       onMessage(stanza) {
         /* Handler method for all incoming single-user chat "message"
         /* Handler method for all incoming single-user chat "message"
          * stanzas.
          * stanzas.
@@ -71387,6 +71430,12 @@ _converse_core__WEBPACK_IMPORTED_MODULE_2__["default"].plugins.add('converse-cha
           to_jid = stanza.getAttribute('to');
           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),
         const from_bare_jid = Strophe.getBareJidFromJid(from_jid),
               from_resource = Strophe.getResourceFromJid(from_jid),
               from_resource = Strophe.getResourceFromJid(from_jid),
               is_me = from_bare_jid === _converse.bare_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 has_body = sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`).length > 0;
         const chatbox = this.getChatBox(contact_jid, attrs, has_body);
         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'),
           const msgid = stanza.getAttribute('id'),
                 message = msgid && chatbox.messages.findWhere({
                 message = msgid && chatbox.messages.findWhere({
             msgid
             msgid
@@ -76065,15 +76114,23 @@ _converse_core__WEBPACK_IMPORTED_MODULE_6__["default"].plugins.add('converse-muc
         return data;
         return data;
       },
       },
 
 
-      isDuplicate(message, original_stanza) {
+      isDuplicate(message) {
         const msgid = message.getAttribute('id'),
         const msgid = message.getAttribute('id'),
               jid = message.getAttribute('from');
               jid = message.getAttribute('from');
 
 
         if (msgid) {
         if (msgid) {
-          return this.messages.where({
+          const msg = this.messages.findWhere({
             'msgid': msgid,
             'msgid': msgid,
             'from': jid
             'from': jid
-          }).length;
+          });
+
+          if (msg && msg.get('sender') === 'me' && !msg.get('received')) {
+            msg.save({
+              'received': moment().format()
+            });
+          }
+
+          return msg;
         }
         }
 
 
         return false;
         return false;
@@ -76106,7 +76163,7 @@ _converse_core__WEBPACK_IMPORTED_MODULE_6__["default"].plugins.add('converse-muc
           stanza = forwarded.querySelector('message');
           stanza = forwarded.querySelector('message');
         }
         }
 
 
-        if (this.isDuplicate(stanza, original_stanza)) {
+        if (this.isDuplicate(stanza)) {
           return;
           return;
         }
         }
 
 
@@ -102447,6 +102504,10 @@ __p += '\n        </span>\n        ';
  if (!o.is_me_message) { ;
  if (!o.is_me_message) { ;
 __p += '<div class="chat-msg__body">';
 __p += '<div class="chat-msg__body">';
  } ;
  } ;
+__p += '\n            ';
+ if (o.received) { ;
+__p += ' <span class="fa fa-check chat-msg__receipt">&nbsp;</span> ';
+ } ;
 __p += '\n            ';
 __p += '\n            ';
  if (o.edited) { ;
  if (o.edited) { ;
 __p += ' <i title="' +
 __p += ' <i title="' +

+ 4 - 0
sass/_messages.scss

@@ -256,6 +256,10 @@
                     margin-left: 2.75rem;
                     margin-left: 2.75rem;
                 }
                 }
             }
             }
+
+            .chat-msg__receipt {
+                color: var(--message-receipt-color);
+            }
         }
         }
     }
     }
 
 

+ 1 - 0
sass/_variables.scss

@@ -28,6 +28,7 @@ $font-path: "webfonts/icomoon/fonts/" !default;
     --text-color: #666;
     --text-color: #666;
     --text-color-lighten-15-percent: #8c8c8c; // lighten(#666, 15%)
     --text-color-lighten-15-percent: #8c8c8c; // lighten(#666, 15%)
     --message-text-color: #555;
     --message-text-color: #555;
+    --message-receipt-color: #3AA569; // $green
     --save-button-color: #3AA569; // $green
     --save-button-color: #3AA569; // $green
 
 
     --chat-textarea-color: #666;
     --chat-textarea-color: #666;

+ 2 - 0
spec/http-file-upload.js

@@ -357,6 +357,7 @@
                                 `xmlns="jabber:client">`+
                                 `xmlns="jabber:client">`+
                                     `<body>${message}</body>`+
                                     `<body>${message}</body>`+
                                     `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                                     `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                                    `<request xmlns="urn:xmpp:receipts"/>`+
                                     `<x xmlns="jabber:x:oob">`+
                                     `<x xmlns="jabber:x:oob">`+
                                         `<url>${message}</url>`+
                                         `<url>${message}</url>`+
                                     `</x>`+
                                     `</x>`+
@@ -459,6 +460,7 @@
                                 `xmlns="jabber:client">`+
                                 `xmlns="jabber:client">`+
                                     `<body>${message}</body>`+
                                     `<body>${message}</body>`+
                                     `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                                     `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                                    `<request xmlns="urn:xmpp:receipts"/>`+
                                     `<x xmlns="jabber:x:oob">`+
                                     `<x xmlns="jabber:x:oob">`+
                                         `<url>${message}</url>`+
                                         `<url>${message}</url>`+
                                     `</x>`+
                                     `</x>`+

+ 96 - 0
spec/messages.js

@@ -77,6 +77,7 @@
                     `xmlns="jabber:client">`+
                     `xmlns="jabber:client">`+
                         `<body>But soft, what light through yonder window breaks?</body>`+
                         `<body>But soft, what light through yonder window breaks?</body>`+
                         `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                         `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                        `<request xmlns="urn:xmpp:receipts"/>`+
                         `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
                         `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
                 `</message>`);
                 `</message>`);
             expect(view.model.messages.models.length).toBe(1);
             expect(view.model.messages.models.length).toBe(1);
@@ -181,6 +182,7 @@
                     `xmlns="jabber:client">`+
                     `xmlns="jabber:client">`+
                         `<body>But soft, what light through yonder window breaks?</body>`+
                         `<body>But soft, what light through yonder window breaks?</body>`+
                         `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                         `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                        `<request xmlns="urn:xmpp:receipts"/>`+
                         `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
                         `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
                 `</message>`);
                 `</message>`);
             expect(view.model.messages.models.length).toBe(1);
             expect(view.model.messages.models.length).toBe(1);
@@ -1200,6 +1202,64 @@
             done();
             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(`<received xmlns="${Strophe.NS.RECEIPTS}" id="${msg_id}"/>`);
+            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 () {
         describe("when received from someone else", function () {
 
 
@@ -2010,6 +2070,7 @@
                     `xmlns="jabber:client">`+
                     `xmlns="jabber:client">`+
                         `<body>But soft, what light through yonder window breaks?</body>`+
                         `<body>But soft, what light through yonder window breaks?</body>`+
                         `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                         `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                        `<request xmlns="urn:xmpp:receipts"/>`+
                         `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
                         `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
                 `</message>`);
                 `</message>`);
 
 
@@ -2056,6 +2117,38 @@
             done();
             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 () {
         describe("when received", function () {
 
 
             it("highlights all users mentioned via XEP-0372 references",
             it("highlights all users mentioned via XEP-0372 references",
@@ -2201,6 +2294,7 @@
                             `xmlns="jabber:client">`+
                             `xmlns="jabber:client">`+
                                 `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
                                 `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
                                 `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                                 `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                                `<request xmlns="urn:xmpp:receipts"/>`+
                                 `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                 `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                 `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                 `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                 `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                 `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@localhost" xmlns="urn:xmpp:reference:0"/>`+
@@ -2226,6 +2320,7 @@
                             `xmlns="jabber:client">`+
                             `xmlns="jabber:client">`+
                                 `<body>hello z3r0 gibson sw0rdf1sh, how are you?</body>`+
                                 `<body>hello z3r0 gibson sw0rdf1sh, how are you?</body>`+
                                 `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                                 `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                                `<request xmlns="urn:xmpp:receipts"/>`+
                                 `<reference begin="18" end="27" type="mention" uri="xmpp:sw0rdf1sh@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                 `<reference begin="18" end="27" type="mention" uri="xmpp:sw0rdf1sh@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                 `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                 `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                 `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                 `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@localhost" xmlns="urn:xmpp:reference:0"/>`+
@@ -2274,6 +2369,7 @@
                                 `xmlns="jabber:client">`+
                                 `xmlns="jabber:client">`+
                                     `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
                                     `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
                                     `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                                     `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                                    `<request xmlns="urn:xmpp:receipts"/>`+
                                     `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                     `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                     `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                     `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                     `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@localhost" xmlns="urn:xmpp:reference:0"/>`+
                                     `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@localhost" xmlns="urn:xmpp:reference:0"/>`+

+ 1 - 0
spec/omemo.js

@@ -172,6 +172,7 @@
                             `to="max.frankfurter@localhost" `+
                             `to="max.frankfurter@localhost" `+
                             `type="chat" xmlns="jabber:client">`+
                             `type="chat" xmlns="jabber:client">`+
                     `<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
                     `<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
+                    `<request xmlns="urn:xmpp:receipts"/>`+
                     `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
                     `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
                         `<header sid="123456789">`+
                         `<header sid="123456789">`+
                             `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
                             `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+

+ 1 - 1
src/converse-message-view.js

@@ -86,7 +86,7 @@ converse.plugins.add('converse-message-view', {
                 if (this.model.changed.progress) {
                 if (this.model.changed.progress) {
                     return this.renderFileUploadProgresBar();
                     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) {
                              prop => Object.prototype.hasOwnProperty.call(this.model.changed, prop)).length) {
                     await this.render();
                     await this.render();
                 }
                 }

+ 1 - 0
src/converse-omemo.js

@@ -394,6 +394,7 @@ converse.plugins.add('converse-omemo', {
                         'type': this.get('message_type'),
                         'type': this.get('message_type'),
                         'id': message.get('msgid')
                         'id': message.get('msgid')
                     }).c('body').t(body).up()
                     }).c('body').t(body).up()
+                        .c('request', {'xmlns': Strophe.NS.RECEIPTS}).up()
                         // An encrypted header is added to the message for
                         // An encrypted header is added to the message for
                         // each device that is supposed to receive it.
                         // each device that is supposed to receive it.
                         // These headers simply contain the key that the
                         // These headers simply contain the key that the

+ 36 - 2
src/headless/converse-chatboxes.js

@@ -13,6 +13,7 @@ const { $msg, Backbone, Promise, Strophe, b64_sha1, moment, sizzle, utils, _ } =
 const u = converse.env.utils;
 const u = converse.env.utils;
 
 
 Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
 Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
+Strophe.addNamespace('RECEIPTS', 'urn:xmpp:receipts');
 Strophe.addNamespace('REFERENCE', 'urn:xmpp:reference:0');
 Strophe.addNamespace('REFERENCE', 'urn:xmpp:reference:0');
 
 
 
 
@@ -297,6 +298,24 @@ converse.plugins.add('converse-chatboxes', {
                 return false;
                 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) {
             createMessageStanza (message) {
                 /* Given a _converse.Message Backbone.Model, return the XML
                 /* Given a _converse.Message Backbone.Model, return the XML
                  * stanza that represents it.
                  * stanza that represents it.
@@ -310,7 +329,8 @@ converse.plugins.add('converse-chatboxes', {
                         'type': this.get('message_type'),
                         'type': this.get('message_type'),
                         'id': message.get('edited') && _converse.connection.getUniqueId() || message.get('msgid'),
                         'id': message.get('edited') && _converse.connection.getUniqueId() || message.get('msgid'),
                     }).c('body').t(message.get('message')).up()
                     }).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('is_spoiler')) {
                     if (message.get('spoiler_hint')) {
                     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) {
             onMessage (stanza) {
                 /* Handler method for all incoming single-user chat "message"
                 /* Handler method for all incoming single-user chat "message"
                  * stanzas.
                  * stanzas.
@@ -709,6 +738,11 @@ converse.plugins.add('converse-chatboxes', {
                     to_jid = stanza.getAttribute('to');
                     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),
                 const from_bare_jid = Strophe.getBareJidFromJid(from_jid),
                       from_resource = Strophe.getResourceFromJid(from_jid),
                       from_resource = Strophe.getResourceFromJid(from_jid),
                       is_me = from_bare_jid === _converse.bare_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.
                 // 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 has_body = sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`).length > 0;
                 const chatbox = this.getChatBox(contact_jid, attrs, has_body);
                 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'),
                     const msgid = stanza.getAttribute('id'),
                           message = msgid && chatbox.messages.findWhere({msgid});
                           message = msgid && chatbox.messages.findWhere({msgid});
                     if (!message) {
                     if (!message) {

+ 11 - 3
src/headless/converse-muc.js

@@ -913,13 +913,21 @@ converse.plugins.add('converse-muc', {
                 return data;
                 return data;
             },
             },
 
 
-            isDuplicate (message, original_stanza) {
+            isDuplicate (message) {
                 const msgid = message.getAttribute('id'),
                 const msgid = message.getAttribute('id'),
                       jid = message.getAttribute('from');
                       jid = message.getAttribute('from');
+
                 if (msgid) {
                 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;
                 return false;
+
             },
             },
 
 
             fetchFeaturesIfConfigurationChanged (stanza) {
             fetchFeaturesIfConfigurationChanged (stanza) {
@@ -949,7 +957,7 @@ converse.plugins.add('converse-muc', {
                 if (!_.isNull(forwarded)) {
                 if (!_.isNull(forwarded)) {
                     stanza = forwarded.querySelector('message');
                     stanza = forwarded.querySelector('message');
                 }
                 }
-                if (this.isDuplicate(stanza, original_stanza)) {
+                if (this.isDuplicate(stanza)) {
                     return;
                     return;
                 }
                 }
                 const jid = stanza.getAttribute('from'),
                 const jid = stanza.getAttribute('from'),

+ 1 - 0
src/templates/message.html

@@ -12,6 +12,7 @@
             {[ if (o.is_encrypted) { ]}<span class="fa fa-lock"></span>{[ } ]}
             {[ if (o.is_encrypted) { ]}<span class="fa fa-lock"></span>{[ } ]}
         </span>
         </span>
         {[ if (!o.is_me_message) { ]}<div class="chat-msg__body">{[ } ]}
         {[ if (!o.is_me_message) { ]}<div class="chat-msg__body">{[ } ]}
+            {[ if (o.received) { ]} <span class="fa fa-check chat-msg__receipt">&nbsp;</span> {[ } ]}
             {[ if (o.edited) { ]} <i title="{{{o.__('This message has been edited')}}}" class="fa fa-edit chat-msg__edit-modal"></i> {[ } ]}
             {[ if (o.edited) { ]} <i title="{{{o.__('This message has been edited')}}}" class="fa fa-edit chat-msg__edit-modal"></i> {[ } ]}
             {[ if (!o.is_me_message) { ]}<div class="chat-msg__message">{[ } ]}
             {[ if (!o.is_me_message) { ]}<div class="chat-msg__message">{[ } ]}
                 {[ if (o.is_spoiler) { ]}
                 {[ if (o.is_spoiler) { ]}