Commit da5ca0b5 authored by Christoph Scholz's avatar Christoph Scholz Committed by JC Brand

implement XEP-0184: Message Delivery Receipts

parent e3a5bf7e
......@@ -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
......
......@@ -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;
......
......@@ -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 </span>\n ';
if (!o.is_me_message) { ;
__p += '<div class="chat-msg__body">';
} ;
__p += '\n ';
if (o.received) { ;
__p += ' <span class="fa fa-check chat-msg__receipt">&nbsp;</span> ';
} ;
__p += '\n ';
if (o.edited) { ;
__p += ' <i title="' +
......@@ -256,6 +256,10 @@
margin-left: 2.75rem;
}
}
.chat-msg__receipt {
color: var(--message-receipt-color);
}
}
}
......
......@@ -28,6 +28,7 @@ $font-path: "webfonts/icomoon/fonts/" !default;
--text-color: #666;
--text-color-lighten-15-percent: #8c8c8c; // lighten(#666, 15%)
--message-text-color: #555;
--message-receipt-color: #3AA569; // $green
--save-button-color: #3AA569; // $green
--chat-textarea-color: #666;
......
......@@ -357,6 +357,7 @@
`xmlns="jabber:client">`+
`<body>${message}</body>`+
`<active xmlns="http://jabber.org/protocol/chatstates"/>`+
`<request xmlns="urn:xmpp:receipts"/>`+
`<x xmlns="jabber:x:oob">`+
`<url>${message}</url>`+
`</x>`+
......@@ -459,6 +460,7 @@
`xmlns="jabber:client">`+
`<body>${message}</body>`+
`<active xmlns="http://jabber.org/protocol/chatstates"/>`+
`<request xmlns="urn:xmpp:receipts"/>`+
`<x xmlns="jabber:x:oob">`+
`<url>${message}</url>`+
`</x>`+
......
......@@ -77,6 +77,7 @@
`xmlns="jabber:client">`+
`<body>But soft, what light through yonder window breaks?</body>`+
`<active xmlns="http://jabber.org/protocol/chatstates"/>`+
`<request xmlns="urn:xmpp:receipts"/>`+
`<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
`</message>`);
expect(view.model.messages.models.length).toBe(1);
......@@ -181,6 +182,7 @@
`xmlns="jabber:client">`+
`<body>But soft, what light through yonder window breaks?</body>`+
`<active xmlns="http://jabber.org/protocol/chatstates"/>`+
`<request xmlns="urn:xmpp:receipts"/>`+
`<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
`</message>`);
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(`<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 () {
......@@ -2010,6 +2070,7 @@
`xmlns="jabber:client">`+
`<body>But soft, what light through yonder window breaks?</body>`+
`<active xmlns="http://jabber.org/protocol/chatstates"/>`+
`<request xmlns="urn:xmpp:receipts"/>`+
`<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
`</message>`);
......@@ -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">`+
`<body>hello z3r0 gibson mr.robot, how are you?</body>`+
`<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="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"/>`+
......@@ -2226,6 +2320,7 @@
`xmlns="jabber:client">`+
`<body>hello z3r0 gibson sw0rdf1sh, how are you?</body>`+
`<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="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"/>`+
......@@ -2274,6 +2369,7 @@
`xmlns="jabber:client">`+
`<body>hello z3r0 gibson mr.robot, how are you?</body>`+
`<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="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"/>`+
......
......@@ -172,6 +172,7 @@
`to="max.frankfurter@localhost" `+
`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>`+
`<request xmlns="urn:xmpp:receipts"/>`+
`<encrypted xmlns="eu.siacs.conversations.axolotl">`+
`<header sid="123456789">`+
`<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
......
......@@ -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();
}
......
......@@ -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
......
......@@ -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) {
......
......@@ -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'),
......
......@@ -12,6 +12,7 @@
{[ if (o.is_encrypted) { ]}<span class="fa fa-lock"></span>{[ } ]}
</span>
{[ 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.is_me_message) { ]}<div class="chat-msg__message">{[ } ]}
{[ if (o.is_spoiler) { ]}
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment