Show error message with option to retry when MAM query times out

This commit is contained in:
JC Brand 2019-08-12 20:16:34 +02:00
parent 6307fa698d
commit 89ac4a6969
15 changed files with 405 additions and 250 deletions

View File

@ -5,6 +5,7 @@
- Add a new GUI for moderator actions. You can trigger it by entering `/modtools` in a MUC. - Add a new GUI for moderator actions. You can trigger it by entering `/modtools` in a MUC.
- Reconnect if the server doesn't respond to a `ping` within 10 seconds. - Reconnect if the server doesn't respond to a `ping` within 10 seconds.
- Don't query for MAM MUC messages before the cached messages have been restored (another cause of duplicate messages). - Don't query for MAM MUC messages before the cached messages have been restored (another cause of duplicate messages).
- Show an error message and option to retry when fetching of the MAM archive times out
## 5.0.0 (2019-08-08) ## 5.0.0 (2019-08-08)

View File

@ -450,7 +450,7 @@ body.converse-fullscreen {
width: 1em; width: 1em;
display: block; display: block;
text-align: center; text-align: center;
margin: 2em; padding: 0.5em 0;
font-size: 24px; font-size: 24px;
} }
.left { .left {

View File

@ -573,7 +573,7 @@
await u.waitUntil(() => view.el.querySelectorAll('.message').length) await u.waitUntil(() => view.el.querySelectorAll('.message').length)
const messages = view.el.querySelectorAll('.message.chat-error'); const messages = view.el.querySelectorAll('.message.chat-error');
expect(messages.length).toBe(1); expect(messages.length).toBe(1);
expect(messages[0].textContent).toBe( expect(messages[0].textContent.trim()).toBe(
'The size of your file, my-juliet.jpg, exceeds the maximum allowed by your server, which is 5 MB.'); 'The size of your file, my-juliet.jpg, exceeds the maximum allowed by your server, which is 5 MB.');
done(); done();
})); }));

View File

@ -772,7 +772,7 @@
* </result> * </result>
* </message> * </message>
*/ */
const msg1 = $msg({'id':'aeb213', 'to':'juliet@capulet.lit/chamber'}) const msg1 = $msg({'id':'aeb212', 'to':'juliet@capulet.lit/chamber'})
.c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'28482-98726-73623'}) .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'28482-98726-73623'})
.c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
.c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
@ -943,7 +943,7 @@
`</query>`+ `</query>`+
`</iq>` `</iq>`
); );
const msg1 = $msg({'id':'aeb213', 'to': contact_jid}) const msg1 = $msg({'id':'aeb212', 'to': contact_jid})
.c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'28482-98726-73623'}) .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'28482-98726-73623'})
.c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
.c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
@ -974,6 +974,110 @@
_converse.connection._dataRecv(test_utils.createRequest(stanza)); _converse.connection._dataRecv(test_utils.createRequest(stanza));
done(); done();
})); }));
it("will show an error message if the MAM query times out",
mock.initConverse(
null, ['discoInitialized'], {},
async function (done, _converse) {
const sendIQ = _converse.connection.sendIQ;
let timeout_happened = false;
spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
sendIQ.bind(this)(iq, callback, errback);
if (!timeout_happened) {
if (typeof(iq.tree) === "function") {
iq = iq.tree();
}
if (sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length) {
// We emulate a timeout event
callback(null);
timeout_happened = true;
}
}
});
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 IQ_stanzas = _converse.connection.IQ_stanzas;
let sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length).pop());
let queryid = sent_stanza.querySelector('query').getAttribute('queryid');
expect(Strophe.serialize(sent_stanza)).toBe(
`<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
`<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
`<x type="submit" xmlns="jabber:x:data">`+
`<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
`<field var="with"><value>mercutio@montague.lit</value></field>`+
`</x>`+
`<set xmlns="http://jabber.org/protocol/rsm"><max>50</max><before></before></set>`+
`</query>`+
`</iq>`);
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('type')).toBe('error');
expect(view.model.messages.at(0).get('message')).toBe('Timeout while trying to fetch archived messages.');
let err_message = view.el.querySelector('.message.chat-error');
err_message.querySelector('.retry').click();
expect(err_message.querySelector('.spinner')).not.toBe(null);
while (_converse.connection.IQ_stanzas.length) {
_converse.connection.IQ_stanzas.pop();
}
sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length).pop());
queryid = sent_stanza.querySelector('query').getAttribute('queryid');
expect(Strophe.serialize(sent_stanza)).toBe(
`<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
`<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
`<x type="submit" xmlns="jabber:x:data">`+
`<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
`<field var="with"><value>mercutio@montague.lit</value></field>`+
`</x>`+
`<set xmlns="http://jabber.org/protocol/rsm"><max>50</max><before></before></set>`+
`</query>`+
`</iq>`);
const msg1 = $msg({'id':'aeb212', 'to': contact_jid})
.c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid': queryid, 'id':'28482-98726-73623'})
.c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
.c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
.c('message', {
'xmlns':'jabber:client',
'to': contact_jid,
'from': _converse.bare_jid,
'type':'chat' })
.c('body').t("Call me but love, and I'll be new baptized;");
_converse.connection._dataRecv(test_utils.createRequest(msg1));
const msg2 = $msg({'id':'aeb213', 'to': contact_jid})
.c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid': queryid, 'id':'28482-98726-73624'})
.c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
.c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:18:25Z'}).up()
.c('message', {
'xmlns':'jabber:client',
'to': contact_jid,
'from': _converse.bare_jid,
'type':'chat' })
.c('body').t("Henceforth I never will be Romeo.");
_converse.connection._dataRecv(test_utils.createRequest(msg2));
const stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('id')})
.c('fin', {'xmlns': 'urn:xmpp:mam:2', 'complete': true})
.c('set', {'xmlns': 'http://jabber.org/protocol/rsm'})
.c('first', {'index': '0'}).t('28482-98726-73623').up()
.c('last').t('28482-98726-73624').up()
.c('count').t('2');
_converse.connection._dataRecv(test_utils.createRequest(stanza));
await u.waitUntil(() => view.model.messages.length === 2, 500);
err_message = view.el.querySelector('.message.chat-error');
expect(err_message).toBe(null);
done();
}));
}); });
}); });
})); }));

View File

@ -1761,7 +1761,7 @@
.t('Server-to-server connection failed: Connecting failed: connection timeout'); .t('Server-to-server connection failed: Connecting failed: connection timeout');
_converse.connection._dataRecv(test_utils.createRequest(stanza)); _converse.connection._dataRecv(test_utils.createRequest(stanza));
await new Promise((resolve, reject) => view.once('messageInserted', resolve)); await new Promise((resolve, reject) => view.once('messageInserted', resolve));
expect(chat_content.querySelector('.chat-error').textContent).toEqual(error_txt); expect(chat_content.querySelector('.chat-error').textContent.trim()).toEqual(error_txt);
stanza = $msg({ stanza = $msg({
'to': _converse.connection.jid, 'to': _converse.connection.jid,
'type': 'error', 'type': 'error',

File diff suppressed because it is too large Load Diff

View File

@ -1430,7 +1430,7 @@
await u.waitUntil(() => !view.model.get('omemo_supported')); await u.waitUntil(() => !view.model.get('omemo_supported'));
expect(view.el.querySelector('.chat-error').textContent).toBe( expect(view.el.querySelector('.chat-error').textContent.trim()).toBe(
"oldguy doesn't appear to have a client that supports OMEMO. "+ "oldguy doesn't appear to have a client that supports OMEMO. "+
"Encrypted chat will no longer be possible in this grouchat." "Encrypted chat will no longer be possible in this grouchat."
); );

View File

@ -15,6 +15,7 @@ import tpl_file_progress from "templates/file_progress.html";
import tpl_info from "templates/info.html"; import tpl_info from "templates/info.html";
import tpl_message from "templates/message.html"; import tpl_message from "templates/message.html";
import tpl_message_versions_modal from "templates/message_versions_modal.html"; import tpl_message_versions_modal from "templates/message_versions_modal.html";
import tpl_spinner from "templates/spinner.html";
import u from "@converse/headless/utils/emoji"; import u from "@converse/headless/utils/emoji";
import xss from "xss/dist/xss"; import xss from "xss/dist/xss";
@ -80,7 +81,8 @@ converse.plugins.add('converse-message-view', {
_converse.MessageView = _converse.ViewWithAvatar.extend({ _converse.MessageView = _converse.ViewWithAvatar.extend({
events: { events: {
'click .chat-msg__edit-modal': 'showMessageVersionsModal' 'click .chat-msg__edit-modal': 'showMessageVersionsModal',
'click .retry': 'onRetryClicked'
}, },
initialize () { initialize () {
@ -164,6 +166,16 @@ converse.plugins.add('converse-message-view', {
} }
}, },
async onRetryClicked () {
this.showSpinner();
await this.model.error.retry();
this.model.destroy();
},
showSpinner () {
this.el.innerHTML = tpl_spinner();
},
onMessageEdited () { onMessageEdited () {
if (this.model.get('is_archived')) { if (this.model.get('is_archived')) {
return; return;

View File

@ -2,7 +2,6 @@
* -------------------- * --------------------
* Any of the following components may be removed if they're not needed. * Any of the following components may be removed if they're not needed.
*/ */
import "@converse/headless/headless"; import "@converse/headless/headless";
import "converse-autocomplete"; import "converse-autocomplete";
import "converse-bookmark-views"; // Views for XEP-0048 Bookmarks import "converse-bookmark-views"; // Views for XEP-0048 Bookmarks

View File

@ -90,7 +90,8 @@ converse.plugins.add('converse-chatboxes', {
defaults () { defaults () {
return { return {
'msgid': _converse.connection.getUniqueId(), 'msgid': _converse.connection.getUniqueId(),
'time': (new Date()).toISOString() 'time': (new Date()).toISOString(),
'ephemeral': false
}; };
}, },
@ -134,7 +135,7 @@ converse.plugins.add('converse-chatboxes', {
}, },
isEphemeral () { isEphemeral () {
return this.isOnlyChatStateNotification() || this.get('type') === 'error'; return this.isOnlyChatStateNotification() || this.get('ephemeral');
}, },
getDisplayName () { getDisplayName () {
@ -178,7 +179,8 @@ converse.plugins.add('converse-chatboxes', {
_converse.log(e, Strophe.LogLevel.ERROR); _converse.log(e, Strophe.LogLevel.ERROR);
return this.save({ return this.save({
'type': 'error', 'type': 'error',
'message': __("Sorry, could not determine upload URL.") 'message': __("Sorry, could not determine upload URL."),
'ephemeral': true
}); });
} }
const slot = stanza.querySelector('slot'); const slot = stanza.querySelector('slot');
@ -190,7 +192,8 @@ converse.plugins.add('converse-chatboxes', {
} else { } else {
return this.save({ return this.save({
'type': 'error', 'type': 'error',
'message': __("Sorry, could not determine file upload URL.") 'message': __("Sorry, could not determine file upload URL."),
'ephemeral': true
}); });
} }
}, },
@ -228,7 +231,8 @@ converse.plugins.add('converse-chatboxes', {
this.save({ this.save({
'type': 'error', 'type': 'error',
'upload': _converse.FAILURE, 'upload': _converse.FAILURE,
'message': message 'message': message,
'ephemeral': true
}); });
}; };
xhr.open('PUT', this.get('put'), true); xhr.open('PUT', this.get('put'), true);
@ -401,6 +405,13 @@ converse.plugins.add('converse-chatboxes', {
} }
}, },
createMessageFromError (error) {
if (error instanceof _converse.TimeoutError) {
const msg = this.messages.create({'type': 'error', 'message': error.message, 'retry': true});
msg.error = error;
}
},
getOldestMessage () { getOldestMessage () {
for (let i=0; i<this.messages.length; i++) { for (let i=0; i<this.messages.length; i++) {
const message = this.messages.at(i); const message = this.messages.at(i);
@ -798,7 +809,8 @@ converse.plugins.add('converse-chatboxes', {
if (!item) { if (!item) {
this.messages.create({ this.messages.create({
'message': __("Sorry, looks like file upload is not supported by your server."), 'message': __("Sorry, looks like file upload is not supported by your server."),
'type': 'error' 'type': 'error',
'ephemeral': true
}); });
return; return;
} }
@ -809,7 +821,8 @@ converse.plugins.add('converse-chatboxes', {
if (!slot_request_url) { if (!slot_request_url) {
this.messages.create({ this.messages.create({
'message': __("Sorry, looks like file upload is not supported by your server."), 'message': __("Sorry, looks like file upload is not supported by your server."),
'type': 'error' 'type': 'error',
'ephemeral': true
}); });
return; return;
} }
@ -818,7 +831,8 @@ converse.plugins.add('converse-chatboxes', {
return this.messages.create({ return this.messages.create({
'message': __('The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.', '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)), file.name, filesize(max_file_size)),
'type': 'error' 'type': 'error',
'ephemeral': true
}); });
} else { } else {
const message = this.messages.create( const message = this.messages.create(
@ -887,9 +901,12 @@ converse.plugins.add('converse-chatboxes', {
__('Sorry, an error occurred:') + ' ' + error.innerHTML; __('Sorry, an error occurred:') + ' ' + error.innerHTML;
}, },
/**
* Given a message stanza, return the text contained in its body.
* @private
* @param { XMLElement } stanza
*/
getMessageBody (stanza) { getMessageBody (stanza) {
/* Given a message stanza, return the text contained in its body.
*/
const type = stanza.getAttribute('type'); const type = stanza.getAttribute('type');
if (type === 'error') { if (type === 'error') {
return this.getErrorMessage(stanza); return this.getErrorMessage(stanza);

View File

@ -117,6 +117,14 @@ _converse.Collection = Backbone.Collection.extend({
}); });
/**
* Custom error for indicating timeouts
* @namespace _converse
*/
class TimeoutError extends Error {}
_converse.TimeoutError = TimeoutError;
// Make converse pluggable // Make converse pluggable
pluggable.enable(_converse, '_converse', 'pluggable'); pluggable.enable(_converse, '_converse', 'pluggable');

View File

@ -128,6 +128,11 @@ converse.plugins.add('converse-mam', {
const result = await _converse.api.archive.query(query); const result = await _converse.api.archive.query(query);
result.messages.forEach(message_handler); result.messages.forEach(message_handler);
if (result.error) {
result.error.retry = () => this.fetchArchivedMessages(options, page);
this.createMessageFromError(result.error);
}
if (page && result.rsm) { if (page && result.rsm) {
if (page === 'forwards') { if (page === 'forwards') {
options = result.rsm.next(_converse.archived_messages_page_size, options.before); options = result.rsm.next(_converse.archived_messages_page_size, options.before);
@ -298,9 +303,9 @@ converse.plugins.add('converse-mam', {
* * `index` * * `index`
* * `count` * * `count`
* @throws {Error} An error is thrown if the XMPP server responds with an error. * @throws {Error} An error is thrown if the XMPP server responds with an error.
* @returns {Promise<Object>} A promise which resolves to an object which * @returns { (Promise<Object> | _converse.TimeoutError) } A promise which resolves
* will have keys `messages` and `rsm` which contains a _converse.RSM object * to an object which will have keys `messages` and `rsm` which contains a _converse.RSM
* on which "next" or "previous" can be called before passing it in again * object on which "next" or "previous" can be called before passing it in again
* to this method, to get the next or previous page in the result set. * to this method, to get the next or previous page in the result set.
* *
* @example * @example
@ -506,17 +511,22 @@ converse.plugins.add('converse-mam', {
return true; return true;
}, Strophe.NS.MAM); }, Strophe.NS.MAM);
let iq_result, rsm; let error;
try { const iq_result = await _converse.api.sendIQ(stanza, _converse.message_archiving_timeout, false)
iq_result = await _converse.api.sendIQ(stanza, _converse.message_archiving_timeout) if (iq_result === null) {
} catch (e) { const err_msg = "Timeout while trying to fetch archived messages.";
_converse.log( _converse.log(err_msg, Strophe.LogLevel.ERROR);
"Error or timeout while trying to fetch "+ error = new _converse.TimeoutError(err_msg);
"archived messages", Strophe.LogLevel.ERROR); return { messages, error };
_converse.log(e, Strophe.LogLevel.ERROR);
} else if (u.isErrorStanza(iq_result)) {
_converse.log("Error stanza received while trying to fetch archived messages", Strophe.LogLevel.ERROR);
_converse.log(iq_result, Strophe.LogLevel.ERROR);
return { messages };
} }
_converse.connection.deleteHandler(message_handler); _converse.connection.deleteHandler(message_handler);
let rsm;
const fin = iq_result && sizzle(`fin[xmlns="${Strophe.NS.MAM}"]`, iq_result).pop(); const fin = iq_result && sizzle(`fin[xmlns="${Strophe.NS.MAM}"]`, iq_result).pop();
if (fin && [null, 'false'].includes(fin.getAttribute('complete'))) { if (fin && [null, 'false'].includes(fin.getAttribute('complete'))) {
const set = sizzle(`set[xmlns="${Strophe.NS.RSM}"]`, fin).pop(); const set = sizzle(`set[xmlns="${Strophe.NS.RSM}"]`, fin).pop();
@ -525,7 +535,7 @@ converse.plugins.add('converse-mam', {
Object.assign(rsm, Object.assign(pick(options, [...MAM_ATTRIBUTES, ..._converse.RSM_ATTRIBUTES]), rsm)); Object.assign(rsm, Object.assign(pick(options, [...MAM_ATTRIBUTES, ..._converse.RSM_ATTRIBUTES]), rsm));
} }
} }
return { messages, rsm } return { messages, rsm, error };
} }
} }
}); });

View File

@ -1550,7 +1550,8 @@ converse.plugins.add('converse-muc', {
} else { } else {
const attrs = { const attrs = {
'type': 'error', 'type': 'error',
'message': text 'message': text,
'ephemeral': true
} }
this.messages.create(attrs); this.messages.create(attrs);
} }

View File

@ -1,8 +1,12 @@
{[ if (o.render_message) { ]} <div class="message chat-info {{{o.extra_classes}}}" data-isodate="{{{o.isodate}}}" {[ if (o.data_name) { ]} data-{{{o.data_name}}}="{{{o.data_value}}}"{[ } ]}>
<!-- XXX: Should only ever be rendered if the message text has been sanitized already --> {[ if (o.render_message) {
<div class="message chat-info {{{o.extra_classes}}}" // XXX: Should only ever be rendered if the message text has been sanitized already
data-isodate="{{{o.isodate}}}" {[ if (o.data_name) { ]} data-{{{o.data_name}}}="{{{o.data_value}}}"{[ } ]}>{{o.message}}</div> ]}
{{o.message}}
{[ } else { ]} {[ } else { ]}
<div class="message chat-info {{{o.extra_classes}}}" {{{o.message}}}
data-isodate="{{{o.isodate}}}" {[ if (o.data_name) { ]} data-{{{o.data_name}}}="{{{o.data_value}}}"{[ } ]}>{{{o.message}}}</div>
{[ } ]} {[ } ]}
{[ if (o.retry) { ]}
<a class="retry">Retry</a>
{[ } ]}
</div>

View File

@ -6,7 +6,6 @@
<meta name="description" content="Converse XMPP Chat" /> <meta name="description" content="Converse XMPP Chat" />
<link rel="shortcut icon" type="image/png" href="../node_modules/jasmine-core/images/jasmine_favicon.png"> <link rel="shortcut icon" type="image/png" href="../node_modules/jasmine-core/images/jasmine_favicon.png">
<link rel="stylesheet" type="text/css" media="screen" href="../node_modules/jasmine-core/lib/jasmine-core/jasmine.css"> <link rel="stylesheet" type="text/css" media="screen" href="../node_modules/jasmine-core/lib/jasmine-core/jasmine.css">
<link type="text/css" rel="stylesheet" media="screen" href="../dist/website.css" />
<link type="text/css" rel="stylesheet" media="screen" href="../dist/converse.css" /> <link type="text/css" rel="stylesheet" media="screen" href="../dist/converse.css" />
<script src="../dist/converse.js"></script> <script src="../dist/converse.js"></script>
<script data-main="runner" src="../node_modules/requirejs/require.js"></script> <script data-main="runner" src="../node_modules/requirejs/require.js"></script>