// Converse.js (A browser based XMPP chat client) // https://conversejs.org // // Copyright (c) 2012-2017, Jan-Carel Brand // Licensed under the Mozilla Public License (MPLv2) // /*global define */ // XEP-0059 Result Set Management import "./converse-disco"; import "strophejs-plugin-rsm"; import converse from "./converse-core"; import sizzle from "sizzle"; const CHATROOMS_TYPE = 'chatroom'; const { Promise, Strophe, $iq, _, moment } = converse.env; const u = converse.env.utils; const RSM_ATTRIBUTES = ['max', 'first', 'last', 'after', 'before', 'index', 'count']; // XEP-0313 Message Archive Management const MAM_ATTRIBUTES = ['with', 'start', 'end']; function getMessageArchiveID (stanza) { // See https://xmpp.org/extensions/xep-0313.html#results // // The result messages MUST contain a element with an 'id' // attribute that gives the current message's archive UID const result = sizzle(`result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop(); if (!_.isUndefined(result)) { return result.getAttribute('id'); } } function queryForArchivedMessages (_converse, options, callback, errback) { /* Internal function, called by the "archive.query" API method. */ let date; if (_.isFunction(options)) { callback = options; errback = callback; options = null; } const queryid = _converse.connection.getUniqueId(); const attrs = {'type':'set'}; if (options && options.groupchat) { if (!options['with']) { // eslint-disable-line dot-notation throw new Error( 'You need to specify a "with" value containing '+ 'the chat room JID, when querying groupchat messages.'); } attrs.to = options['with']; // eslint-disable-line dot-notation } const stanza = $iq(attrs).c('query', {'xmlns':Strophe.NS.MAM, 'queryid':queryid}); if (options) { stanza.c('x', {'xmlns':Strophe.NS.XFORM, 'type': 'submit'}) .c('field', {'var':'FORM_TYPE', 'type': 'hidden'}) .c('value').t(Strophe.NS.MAM).up().up(); if (options['with'] && !options.groupchat) { // eslint-disable-line dot-notation stanza.c('field', {'var':'with'}).c('value') .t(options['with']).up().up(); // eslint-disable-line dot-notation } _.each(['start', 'end'], function (t) { if (options[t]) { date = moment(options[t]); if (date.isValid()) { stanza.c('field', {'var':t}).c('value').t(date.format()).up().up(); } else { throw new TypeError(`archive.query: invalid date provided for: ${t}`); } } }); stanza.up(); if (options instanceof Strophe.RSM) { stanza.cnode(options.toXML()); } else if (_.intersection(RSM_ATTRIBUTES, _.keys(options)).length) { stanza.cnode(new Strophe.RSM(options).toXML()); } } const messages = []; const message_handler = _converse.connection.addHandler((message) => { if (options.groupchat && message.getAttribute('from') !== options['with']) { // eslint-disable-line dot-notation return true; } const result = message.querySelector('result'); if (!_.isNull(result) && result.getAttribute('queryid') === queryid) { messages.push(message); } return true; }, Strophe.NS.MAM); _converse.api.sendIQ(stanza, _converse.message_archiving_timeout) .then(iq => { _converse.connection.deleteHandler(message_handler); if (_.isFunction(callback)) { const set = iq.querySelector('set'); let rsm; if (!_.isUndefined(set)) { rsm = new Strophe.RSM({xml: set}); _.extend(rsm, _.pick(options, _.concat(MAM_ATTRIBUTES, ['max']))); } callback(messages, rsm); } }).catch(e => { _converse.connection.deleteHandler(message_handler); if (_.isFunction(errback)) { errback.apply(this, arguments); } return; }); } converse.plugins.add('converse-mam', { dependencies: ['converse-chatview', 'converse-muc', 'converse-muc-views'], overrides: { // Overrides mentioned here will be picked up by converse.js's // plugin architecture they will replace existing methods on the // relevant objects or classes. // // New functions which don't exist yet can also be added. ChatBox: { async getMessageAttributesFromStanza (message, original_stanza) { const attrs = await this.__super__.getMessageAttributesFromStanza.apply(this, arguments); attrs.archive_id = getMessageArchiveID(original_stanza); return attrs; } }, ChatBoxView: { render () { const result = this.__super__.render.apply(this, arguments); if (!this.disable_mam) { this.content.addEventListener('scroll', _.debounce(this.onScroll.bind(this), 100)); } return result; }, fetchNewestMessages () { /* Fetches messages that might have been archived *after* * the last archived message in our local cache. */ if (this.disable_mam) { return; } const { _converse } = this.__super__, most_recent_msg = u.getMostRecentMessage(this.model); if (_.isNil(most_recent_msg)) { this.fetchArchivedMessages(); } else { const archive_id = most_recent_msg.get('archive_id'); if (archive_id) { this.fetchArchivedMessages({ 'after': most_recent_msg.get('archive_id') }); } else { this.fetchArchivedMessages({ 'start': most_recent_msg.get('time') }); } } }, fetchArchivedMessagesIfNecessary () { /* Check if archived messages should be fetched, and if so, do so. */ if (this.disable_mam || this.model.get('mam_initialized')) { return; } const { _converse } = this.__super__; _converse.api.disco.supports(Strophe.NS.MAM, _converse.bare_jid).then( (result) => { // Success if (result.length) { this.fetchArchivedMessages(); } this.model.save({'mam_initialized': true}); }, () => { // Error _converse.log( "Error or timeout while checking for MAM support", Strophe.LogLevel.ERROR ); } ).catch((msg) => { this.clearSpinner(); _converse.log(msg, Strophe.LogLevel.FATAL); }); }, fetchArchivedMessages (options) { const { _converse } = this.__super__; if (this.disable_mam) { return; } const is_groupchat = this.model.get('type') === CHATROOMS_TYPE; let mam_jid, message_handler; if (is_groupchat) { mam_jid = this.model.get('jid'); message_handler = this.model.onMessage.bind(this.model); } else { mam_jid = _converse.bare_jid; message_handler = _converse.chatboxes.onMessage.bind(_converse.chatboxes) } _converse.api.disco.supports(Strophe.NS.MAM, mam_jid).then( (results) => { // Success if (!results.length) { return; } this.addSpinner(); _converse.api.archive.query( // TODO: only query from the last message we have // in our history _.extend({ 'groupchat': is_groupchat, 'before': '', // Page backwards from the most recent message 'max': _converse.archived_messages_page_size, 'with': this.model.get('jid'), }, options), (messages) => { // Success this.clearSpinner(); _.each(messages, message_handler); }, e => { // Error this.clearSpinner(); _converse.log( "Error or timeout while trying to fetch "+ "archived messages", Strophe.LogLevel.ERROR); _converse.log(e, Strophe.LogLevel.ERROR); } ); }, () => { // Error _converse.log( "Error or timeout while checking for MAM support", Strophe.LogLevel.ERROR ); } ).catch((msg) => { this.clearSpinner(); _converse.log(msg, Strophe.LogLevel.FATAL); }); }, onScroll (ev) { const { _converse } = this.__super__; if (this.content.scrollTop === 0 && this.model.messages.length) { const oldest_message = this.model.messages.at(0); const archive_id = oldest_message.get('archive_id'); if (archive_id) { this.fetchArchivedMessages({ 'before': archive_id }); } else { this.fetchArchivedMessages({ 'end': oldest_message.get('time') }); } } }, }, ChatRoom: { isDuplicate (message, original_stanza) { const result = this.__super__.isDuplicate.apply(this, arguments); if (result) { return result; } const archive_id = getMessageArchiveID(original_stanza); if (archive_id) { return this.messages.filter({'archive_id': archive_id}).length > 0; } } }, ChatRoomView: { initialize () { const { _converse } = this.__super__; this.__super__.initialize.apply(this, arguments); this.model.on('change:mam_enabled', this.fetchArchivedMessagesIfNecessary, this); this.model.on('change:connection_status', this.fetchArchivedMessagesIfNecessary, this); }, renderChatArea () { const result = this.__super__.renderChatArea.apply(this, arguments); if (!this.disable_mam) { this.content.addEventListener('scroll', _.debounce(this.onScroll.bind(this), 100)); } return result; }, fetchArchivedMessagesIfNecessary () { if (this.model.get('connection_status') !== converse.ROOMSTATUS.ENTERED || !this.model.get('mam_enabled') || this.model.get('mam_initialized')) { return; } this.fetchArchivedMessages(); this.model.save({'mam_initialized': true}); } } }, initialize () { /* The initialize function gets called as soon as the plugin is * loaded by Converse.js's plugin machinery. */ const { _converse } = this; _converse.api.settings.update({ archived_messages_page_size: '50', message_archiving: undefined, // Supported values are 'always', 'never', 'roster' (https://xmpp.org/extensions/xep-0313.html#prefs) message_archiving_timeout: 20000, // Time (in milliseconds) to wait before aborting MAM request }); _converse.onMAMError = function (iq) { if (iq.querySelectorAll('feature-not-implemented').length) { _converse.log( "Message Archive Management (XEP-0313) not supported by this server", Strophe.LogLevel.WARN); } else { _converse.log( "An error occured while trying to set archiving preferences.", Strophe.LogLevel.ERROR); _converse.log(iq); } }; _converse.onMAMPreferences = function (feature, iq) { /* Handle returned IQ stanza containing Message Archive * Management (XEP-0313) preferences. * * XXX: For now we only handle the global default preference. * The XEP also provides for per-JID preferences, which is * currently not supported in converse.js. * * Per JID preferences will be set in chat boxes, so it'll * probbaly be handled elsewhere in any case. */ const preference = sizzle(`prefs[xmlns="${Strophe.NS.MAM}"]`, iq).pop(); const default_pref = preference.getAttribute('default'); if (default_pref !== _converse.message_archiving) { const stanza = $iq({'type': 'set'}) .c('prefs', { 'xmlns':Strophe.NS.MAM, 'default':_converse.message_archiving }); _.each(preference.children, child => stanza.cnode(child).up()); // XXX: Strictly speaking, the server should respond with the updated prefs // (see example 18: https://xmpp.org/extensions/xep-0313.html#config) // but Prosody doesn't do this, so we don't rely on it. _converse.api.sendIQ(stanza) .then(() => feature.save({'preferences': {'default':_converse.message_archiving}})) .catch(_converse.onMAMError); } else { feature.save({'preferences': {'default':_converse.message_archiving}}); } }; /* Event handlers */ _converse.on('serviceDiscovered', (feature) => { const prefs = feature.get('preferences') || {}; if (feature.get('var') === Strophe.NS.MAM && prefs['default'] !== _converse.message_archiving && // eslint-disable-line dot-notation !_.isUndefined(_converse.message_archiving) ) { // Ask the server for archiving preferences _converse.api.sendIQ($iq({'type': 'get'}).c('prefs', {'xmlns': Strophe.NS.MAM})) .then(_.partial(_converse.onMAMPreferences, feature)) .catch(_converse.onMAMError); } }); _converse.on('addClientFeatures', () => { _converse.api.disco.own.features.add(Strophe.NS.MAM); }); _converse.on('afterMessagesFetched', (chatboxview) => { chatboxview.fetchNewestMessages(); }); _converse.on('reconnected', () => { const private_chats = _converse.chatboxviews.filter( (view) => _.at(view, 'model.attributes.type')[0] === 'chatbox' ); _.each(private_chats, (view) => view.fetchNewestMessages()) }); _.extend(_converse.api, { /** * The [XEP-0313](https://xmpp.org/extensions/xep-0313.html) Message Archive Management API * * Enables you to query an XMPP server for archived messages. * * See also the [message-archiving](/docs/html/configuration.html#message-archiving) * option in the configuration settings section, which you'll * usually want to use in conjunction with this API. * * @namespace _converse.api.archive * @memberOf _converse.api */ 'archive': { /** * Query for archived messages. * * The options parameter can also be an instance of * Strophe.RSM to enable easy querying between results pages. * * @method _converse.api.archive.query * @param {(Object|Strophe.RSM)} options Query parameters, either * MAM-specific or also for Result Set Management. * Can be either an object or an instance of Strophe.RSM. * Valid query parameters are: * * `with` * * `start` * * `end` * * `first` * * `last` * * `after` * * `before` * * `index` * * `count` * @param {Function} callback A function to call whenever * we receive query-relevant stanza. * When the callback is called, a Strophe.RSM object is * returned 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. * @param {Function} errback A function to call when an * error stanza is received, for example when it * doesn't support message archiving. * * @example * // Requesting all archived messages * // ================================ * // * // The simplest query that can be made is to simply not pass in any parameters. * // Such a query will return all archived messages for the current user. * // * // Generally, you'll however always want to pass in a callback method, to receive * // the returned messages. * * this._converse.api.archive.query( * (messages) => { * // Do something with the messages, like showing them in your webpage. * }, * (iq) => { * // The query was not successful, perhaps inform the user? * // The IQ stanza returned by the XMPP server is passed in, so that you * // may inspect it and determine what the problem was. * } * ) * @example * // Waiting until server support has been determined * // ================================================ * // * // The query method will only work if Converse has been able to determine that * // the server supports MAM queries, otherwise the following error will be raised: * // * // "This server does not support XEP-0313, Message Archive Management" * // * // The very first time Converse loads in a browser tab, if you call the query * // API too quickly, the above error might appear because service discovery has not * // yet been completed. * // * // To work solve this problem, you can first listen for the `serviceDiscovered` event, * // through which you can be informed once support for MAM has been determined. * * _converse.api.listen.on('serviceDiscovered', function (feature) { * if (feature.get('var') === converse.env.Strophe.NS.MAM) { * _converse.api.archive.query() * } * }); * * @example * // Requesting all archived messages for a particular contact or room * // ================================================================= * // * // To query for messages sent between the current user and another user or room, * // the query options need to contain the the JID (Jabber ID) of the user or * // room under the `with` key. * * // For a particular user * this._converse.api.archive.query({'with': 'john@doe.net'}, callback, errback);) * * // For a particular room * this._converse.api.archive.query({'with': 'discuss@conference.doglovers.net'}, callback, errback);) * * @example * // Requesting all archived messages before or after a certain date * // =============================================================== * // * // The `start` and `end` parameters are used to query for messages * // within a certain timeframe. The passed in date values may either be ISO8601 * // formatted date strings, or JavaScript Date objects. * * const options = { * 'with': 'john@doe.net', * 'start': '2010-06-07T00:00:00Z', * 'end': '2010-07-07T13:23:54Z' * }; * this._converse.api.archive.query(options, callback, errback); * * @example * // Limiting the amount of messages returned * // ======================================== * // * // The amount of returned messages may be limited with the `max` parameter. * // By default, the messages are returned from oldest to newest. * * // Return maximum 10 archived messages * this._converse.api.archive.query({'with': 'john@doe.net', 'max':10}, callback, errback); * * @example * // Paging forwards through a set of archived messages * // ================================================== * // * // When limiting the amount of messages returned per query, you might want to * // repeatedly make a further query to fetch the next batch of messages. * // * // To simplify this usecase for you, the callback method receives not only an array * // with the returned archived messages, but also a special RSM (*Result Set * // Management*) object which contains the query parameters you passed in, as well * // as two utility methods `next`, and `previous`. * // * // When you call one of these utility methods on the returned RSM object, and then * // pass the result into a new query, you'll receive the next or previous batch of * // archived messages. Please note, when calling these methods, pass in an integer * // to limit your results. * * const callback = function (messages, rsm) { * // Do something with the messages, like showing them in your webpage. * // ... * // You can now use the returned "rsm" object, to fetch the next batch of messages: * _converse.api.archive.query(rsm.next(10), callback, errback)) * * } * _converse.api.archive.query({'with': 'john@doe.net', 'max':10}, callback, errback); * * @example * // Paging backwards through a set of archived messages * // =================================================== * // * // To page backwards through the archive, you need to know the UID of the message * // which you'd like to page backwards from and then pass that as value for the * // `before` parameter. If you simply want to page backwards from the most recent * // message, pass in the `before` parameter with an empty string value `''`. * * _converse.api.archive.query({'before': '', 'max':5}, function (message, rsm) { * // Do something with the messages, like showing them in your webpage. * // ... * // You can now use the returned "rsm" object, to fetch the previous batch of messages: * rsm.previous(5); // Call previous method, to update the object's parameters, * // passing in a limit value of 5. * // Now we query again, to get the previous batch. * _converse.api.archive.query(rsm, callback, errback); * } */ 'query': function (options, callback, errback) { if (!_converse.api.connection.connected()) { throw new Error('Can\'t call `api.archive.query` before having established an XMPP session'); } return queryForArchivedMessages(_converse, options, callback, errback); } } }); } });