Commit 1f476b87 authored by JC Brand's avatar JC Brand

Move converse-mam plugin into own folder

parent 8db1f3e3
......@@ -422,7 +422,7 @@ describe("Message Archive Management", function () {
</message>`);
spyOn(view.model, 'getDuplicateMessage').and.callThrough();
spyOn(view.model, 'updateMessage').and.callThrough();
view.model.handleMAMResult({ 'messages': [stanza] });
_converse.handleMAMResult(view.model, { 'messages': [stanza] });
await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
const result = view.model.getDuplicateMessage.calls.all()[0].returnValue
......@@ -466,7 +466,7 @@ describe("Message Archive Management", function () {
</result>
</message>`);
spyOn(view.model, 'getDuplicateMessage').and.callThrough();
view.model.handleMAMResult({ 'messages': [stanza] });
_converse.handleMAMResult(view.model, { 'messages': [stanza] });
await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue
......@@ -496,7 +496,7 @@ describe("Message Archive Management", function () {
</forwarded>
</result>
</message>`);
view.model.handleMAMResult({ 'messages': [stanza] });
_converse.handleMAMResult(view.model, { 'messages': [stanza] });
await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length);
expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
......@@ -516,7 +516,7 @@ describe("Message Archive Management", function () {
</message>`);
spyOn(view.model, 'getDuplicateMessage').and.callThrough();
view.model.handleMAMResult({ 'messages': [stanza] });
_converse.handleMAMResult(view.model, { 'messages': [stanza] });
await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue
......
......@@ -260,7 +260,7 @@ describe("A Groupchat Message", function () {
</message>`);
spyOn(view.model, 'updateMessage');
view.model.handleMAMResult({ 'messages': [stanza] });
_converse.handleMAMResult(view.model, { 'messages': [stanza] });
await u.waitUntil(() => view.model.getDuplicateMessage.calls.count() === 2);
result = await view.model.getDuplicateMessage.calls.all()[1].returnValue;
expect(result instanceof _converse.Message).toBe(true);
......@@ -366,7 +366,7 @@ describe("A Groupchat Message", function () {
}).c('body').t('I am groot').tree();
const view = _converse.api.chatviews.get(muc_jid);
spyOn(converse.env.log, 'error');
await view.model.handleMAMResult({ 'messages': [msg] });
await _converse.handleMAMResult(view.model, { 'messages': [msg] });
await u.waitUntil(() => converse.env.log.error.calls.count());
expect(converse.env.log.error).toHaveBeenCalledWith(
'Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied'
......
......@@ -87,7 +87,7 @@ describe("A delivery receipt", function () {
</message>`);
spyOn(view.model, 'getDuplicateMessage').and.callThrough();
view.model.handleMAMResult({ 'messages': [stanza] });
_converse.handleMAMResult(view.model, { 'messages': [stanza] });
let message_attrs;
_converse.api.listen.on('MAMResult', async data => {
message_attrs = await data.messages[0];
......
......@@ -11,7 +11,7 @@ import "./plugins/chat/index.js"; // RFC-6121 Instant messaging
import "./plugins/chatboxes.js";
import "./plugins/disco.js"; // XEP-0030 Service discovery
import "./plugins/headlines.js"; // Support for headline messages
import "./plugins/mam.js"; // XEP-0313 Message Archive Management
import "./plugins/mam/index.js"; // XEP-0313 Message Archive Management
import "./plugins/muc/index.js"; // XEP-0045 Multi-user chat
import "./plugins/ping.js"; // XEP-0199 XMPP Ping
import "./plugins/pubsub.js"; // XEP-0060 Pubsub
......
/**
* @module converse-mam
* @description XEP-0313 Message Archive Management
* @copyright 2020, the Converse.js contributors
* @license Mozilla Public License (MPLv2)
*/
import "./disco";
import log from '@converse/headless/log';
import sizzle from "sizzle";
import { parseMessage } from '@converse/headless/plugins/chat/parsers';
import { parseMUCMessage } from '@converse/headless/plugins/muc/parsers';
import { RSM } from '@converse/headless/shared/rsm';
import { _converse, api, converse } from "@converse/headless/core";
const { Strophe, $iq, dayjs } = converse.env;
const { NS } = Strophe;
const u = converse.env.utils;
/**
* Fetches messages that might have been archived *after*
* the last archived message in our local cache.
*/
function fetchNewestMessages (model) {
if (model.disable_mam) {
return;
}
const most_recent_msg = model.getMostRecentMessage();
// if clear_messages_on_reconnection is true, than any recent messages
// must have been received *after* connection and we instead must query
// for earlier messages
if (most_recent_msg && !api.settings.get('clear_messages_on_reconnection')) {
const stanza_id = most_recent_msg.get(`stanza_id ${model.get('jid')}`);
if (stanza_id) {
fetchArchivedMessages(model, {'after': stanza_id}, 'forwards');
} else {
fetchArchivedMessages(model, {'start': most_recent_msg.get('time')}, 'forwards');
}
} else {
fetchArchivedMessages(model, {'before': ''});
}
}
async function handleMAMResult (model, result, query, options, page_direction) {
await api.emojis.initialize();
const is_muc = model.get('type') === _converse.CHATROOMS_TYPE;
result.messages = result.messages.map(
s => (is_muc ? parseMUCMessage(s, model, _converse) : parseMessage(s, _converse))
);
/**
* Synchronous event which allows listeners to first do some
* work based on the MAM result before calling the handlers here.
* @event _converse#MAMResult
*/
const data = { query, 'chatbox': model, 'messages': result.messages };
await api.trigger('MAMResult', data, {'synchronous': true});
result.messages.forEach(m => model.queueMessage(m));
if (result.error) {
const event_id = result.error.retry_event_id = u.getUniqueId();
api.listen.once(event_id, () => fetchArchivedMessages(model, options, page_direction));
model.createMessageFromError(result.error);
}
}
/**
* Fetch XEP-0313 archived messages based on the passed in criteria.
* @param { Object } options
* @param { integer } [options.max] - The maximum number of items to return.
* Defaults to "archived_messages_page_size"
* @param { string } [options.after] - The XEP-0359 stanza ID of a message
* after which messages should be returned. Implies forward paging.
* @param { string } [options.before] - The XEP-0359 stanza ID of a message
* before which messages should be returned. Implies backward paging.
* @param { string } [options.end] - A date string in ISO-8601 format,
* before which messages should be returned. Implies backward paging.
* @param { string } [options.start] - A date string in ISO-8601 format,
* after which messages should be returned. Implies forward paging.
* @param { string } [options.with] - The JID of the entity with
* which messages were exchanged.
* @param { boolean } [options.groupchat] - True if archive in groupchat.
* @param { ('forwards'|'backwards')} [page_direction] - Determines whether this function should
* recursively page through the entire result set if a limited number of results were returned.
*/
async function fetchArchivedMessages (model, options={}, page_direction) {
if (model.disable_mam) {
return;
}
const is_muc = model.get('type') === _converse.CHATROOMS_TYPE;
const mam_jid = is_muc ? model.get('jid') : _converse.bare_jid;
if (!(await api.disco.supports(NS.MAM, mam_jid))) {
return;
}
const max = api.settings.get('archived_messages_page_size')
const query = Object.assign({
'groupchat': is_muc,
'max': max,
'with': model.get('jid'),
}, options);
const result = await api.archive.query(query);
await handleMAMResult(model, result, query, options, page_direction);
if (page_direction && result.rsm && !result.complete) {
if (page_direction === 'forwards') {
options = result.rsm.next(max, options.before).query;
} else if (page_direction === 'backwards') {
options = result.rsm.previous(max, options.after).query;
}
return fetchArchivedMessages(model, options, page_direction);
} else {
// TODO: Add a special kind of message which will
// render as a link to fetch further messages, either
// to fetch older messages or to fill in a gap.
}
}
converse.plugins.add('converse-mam', {
dependencies: ['converse-disco', 'converse-muc'],
initialize () {
/* The initialize function gets called as soon as the plugin is
* loaded by Converse.js's plugin machinery.
*/
api.settings.extend({
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) {
log.warn(`Message Archive Management (XEP-0313) not supported by ${iq.getAttribute('from')}`);
} else {
log.error(`Error while trying to set archiving preferences for ${iq.getAttribute('from')}.`);
log.error(iq);
}
};
_converse.onMAMPreferences = function (iq, feature) {
/* 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="${NS.MAM}"]`, iq).pop();
const default_pref = preference.getAttribute('default');
if (default_pref !== api.settings.get('message_archiving')) {
const stanza = $iq({'type': 'set'})
.c('prefs', {
'xmlns':NS.MAM,
'default':api.settings.get('message_archiving')
});
Array.from(preference.children).forEach(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.
api.sendIQ(stanza)
.then(() => feature.save({'preferences': {'default':api.settings.get('message_archiving')}}))
.catch(_converse.onMAMError);
} else {
feature.save({'preferences': {'default':api.settings.get('message_archiving')}});
}
};
function getMAMPrefsFromFeature (feature) {
const prefs = feature.get('preferences') || {};
if (feature.get('var') !== NS.MAM || api.settings.get('message_archiving') === undefined) {
return;
}
if (prefs['default'] !== api.settings.get('message_archiving')) {
api.sendIQ($iq({'type': 'get'}).c('prefs', {'xmlns': NS.MAM}))
.then(iq => _converse.onMAMPreferences(iq, feature))
.catch(_converse.onMAMError);
}
}
function preMUCJoinMAMFetch (muc) {
if (!api.settings.get('muc_show_logs_before_join') ||
!muc.features.get('mam_enabled') ||
muc.get('prejoin_mam_fetched')) {
return;
}
fetchNewestMessages(muc);
muc.save({'prejoin_mam_fetched': true});
}
/************************ BEGIN Event Handlers ************************/
api.listen.on('addClientFeatures', () => api.disco.own.features.add(NS.MAM));
api.listen.on('serviceDiscovered', getMAMPrefsFromFeature);
api.listen.on('chatRoomViewInitialized', view => {
if (api.settings.get('muc_show_logs_before_join')) {
preMUCJoinMAMFetch(view.model);
// If we want to show MAM logs before entering the MUC, we need
// to be informed once it's clear that this MUC supports MAM.
view.model.features.on('change:mam_enabled', () => preMUCJoinMAMFetch(view.model));
}
});
api.listen.on('enteredNewRoom', muc => muc.features.get('mam_enabled') && fetchNewestMessages(muc));
api.listen.on('chatReconnected', chat => {
// XXX: For MUCs, we listen to enteredNewRoom instead
if (chat.get('type') === _converse.PRIVATE_CHAT_TYPE) {
fetchNewestMessages(chat);
}
});
api.listen.on('afterMessagesFetched', chat => {
// XXX: We don't want to query MAM every time this is triggered
// since it's not necessary when the chat is restored from cache.
// (given that BOSH or SMACKS will ensure that you get messages
// sent during the reload).
// With MUCs we can listen for `enteredNewRoom`.
if (chat.get('type') === _converse.PRIVATE_CHAT_TYPE && !_converse.connection.restored) {
fetchNewestMessages(chat);
}
});
/************************ END Event Handlers **************************/
/************************ BEGIN API ************************/
Object.assign(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: {
/**
* @typedef { module:converse-rsm~RSMQueryParameters } MAMFilterParameters
* Filter parameters which can be used to filter a MAM XEP-0313 archive
* @property { String } [end] - A date string in ISO-8601 format, before which messages should be returned. Implies backward paging.
* @property { String } [start] - A date string in ISO-8601 format, after which messages should be returned. Implies forward paging.
* @property { String } [with] - A JID against which to match messages, according to either their `to` or `from` attributes.
* An item in a MUC archive matches if the publisher of the item matches the JID.
* If `with` is omitted, all messages that match the rest of the query will be returned, regardless of to/from
* addresses of each message.
*/
/**
* The options that can be passed in to the { @link _converse.api.archive.query } method
* @typedef { module:converse-mam~MAMFilterParameters } ArchiveQueryOptions
* @property { Boolean } [groupchat=false] - Whether the MAM archive is for a groupchat.
*/
/**
* Query for archived messages.
*
* The options parameter can also be an instance of
* RSM to enable easy querying between results pages.
*
* @method _converse.api.archive.query
* @param { module:converse-mam~ArchiveQueryOptions } options - An object containing query parameters
* @throws {Error} An error is thrown if the XMPP server responds with an error.
* @returns { Promise<module:converse-mam~MAMQueryResult> } A promise which resolves
* to a { @link module:converse-mam~MAMQueryResult } object.
*
* @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.
*
* let result;
* try {
* result = await api.archive.query();
* } catch (e) {
* // 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.
* }
* // Do something with the messages, like showing them in your webpage.
* result.messages.forEach(m => this.showMessage(m));
*
* @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
* let result;
* try {
* result = await api.archive.query({'with': 'john@doe.net'});
* } catch (e) {
* // The query was not successful
* }
*
* // For a particular room
* let result;
* try {
* result = await api.archive.query({'with': 'discuss@conference.doglovers.net', 'groupchat': true});
* } catch (e) {
* // The query was not successful
* }
*
* @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'
* };
* let result;
* try {
* result = await api.archive.query(options);
* } catch (e) {
* // The query was not successful
* }
*
* @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
* let result;
* try {
* result = await api.archive.query({'with': 'john@doe.net', 'max':10});
* } catch (e) {
* // The query was not successful
* }
*
* @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 options = {'with': 'john@doe.net', 'max':10};
* let result;
* try {
* result = await api.archive.query(options);
* } catch (e) {
* // The query was not successful
* }
* // Do something with the messages, like showing them in your webpage.
* result.messages.forEach(m => this.showMessage(m));
*
* while (!result.complete) {
* try {
* result = await api.archive.query(Object.assign(options, rsm.next(10).query));
* } catch (e) {
* // The query was not successful
* }
* // Do something with the messages, like showing them in your webpage.
* result.messages.forEach(m => this.showMessage(m));
* }
*
* @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 `''`.
*
* let result;
* const options = {'before': '', 'max':5};
* try {
* result = await api.archive.query(options);
* } catch (e) {
* // The query was not successful
* }
* // Do something with the messages, like showing them in your webpage.
* result.messages.forEach(m => this.showMessage(m));
*
* // Now we query again, to get the previous batch.
* try {
* result = await api.archive.query(Object.assign(options, rsm.previous(5).query));
* } catch (e) {
* // The query was not successful
* }
* // Do something with the messages, like showing them in your webpage.
* result.messages.forEach(m => this.showMessage(m));
*
*/
async query (options) {
if (!api.connection.connected()) {
throw new Error('Can\'t call `api.archive.query` before having established an XMPP session');
}
const attrs = {'type':'set'};
if (options && options.groupchat) {
if (!options['with']) {
throw new Error(
'You need to specify a "with" value containing '+
'the chat room JID, when querying groupchat messages.');
}
attrs.to = options['with'];
}
const jid = attrs.to || _converse.bare_jid;
const supported = await api.disco.supports(NS.MAM, jid);
if (!supported) {
log.warn(`Did not fetch MAM archive for ${jid} because it doesn't support ${NS.MAM}`);
return {'messages': []};
}
const queryid = u.getUniqueId();
const stanza = $iq(attrs).c('query', {'xmlns':NS.MAM, 'queryid':queryid});
if (options) {
stanza.c('x', {'xmlns':NS.XFORM, 'type': 'submit'})
.c('field', {'var':'FORM_TYPE', 'type': 'hidden'})
.c('value').t(NS.MAM).up().up();
if (options['with'] && !options.groupchat) {
stanza.c('field', {'var':'with'}).c('value')
.t(options['with']).up().up();
}
['start', 'end'].forEach(t => {
if (options[t]) {
const date = dayjs(options[t]);
if (date.isValid()) {
stanza.c('field', {'var':t}).c('value').t(date.toISOString()).up().up();
} else {
throw new TypeError(`archive.query: invalid date provided for: ${t}`);
}
}
});
stanza.up();
const rsm = new RSM(options);
if (Object.keys(rsm.query).length) {
stanza.cnode(rsm.toXML());
}
}
const messages = [];
const message_handler = _converse.connection.addHandler(stanza => {
const result = sizzle(`message > result[xmlns="${NS.MAM}"]`, stanza).pop();
if (result === undefined || result.getAttribute('queryid') !== queryid) {
return true;
}
const from = stanza.getAttribute('from') || _converse.bare_jid;
if (options.groupchat) {
if (from !== options['with']) {
log.warn(`Ignoring alleged groupchat MAM message from ${stanza.getAttribute('from')}`);
return true;
}
} else if (from !== _converse.bare_jid) {
log.warn(`Ignoring alleged MAM message from ${stanza.getAttribute('from')}`);
return true;
}
messages.push(stanza);
return true;
}, NS.MAM);
let error;
const iq_result = await api.sendIQ(stanza, api.settings.get('message_archiving_timeout'), false)
if (iq_result === null) {
const { __ } = _converse;
const err_msg = __("Timeout while trying to fetch archived messages.");
log.error(err_msg);
error = new _converse.TimeoutError(err_msg);
return { messages, error };
} else if (u.isErrorStanza(iq_result)) {
const { __ } = _converse;
const err_msg = __('An error occurred while querying for archived messages.');
log.error(err_msg);
log.error(iq_result);
error = new Error(err_msg);
return { messages, error };
}
_converse.connection.deleteHandler(message_handler);
let rsm;
const fin = iq_result && sizzle(`fin[xmlns="${NS.MAM}"]`, iq_result).pop();
const complete = fin?.getAttribute('complete') === 'true'
const set = sizzle(`set[xmlns="${NS.RSM}"]`, fin).pop();
if (set) {
rsm = new RSM({...options, 'xml': set});
}
/**
* @typedef { Object } MAMQueryResult
* @property { Array } messages
* @property { RSM } [rsm] - An instance of { @link RSM }.
* You can call `next()` or `previous()` on this instance,
* to get the RSM query parameters for the next or previous
* page in the result set.
* @property { Boolean } complete
* @property { Error } [error]
*/
return { messages, rsm, complete };
}
}
});
/************************ END API ************************/
}
});
import { RSM } from '@converse/headless/shared/rsm';
import log from '@converse/headless/log';
import sizzle from "sizzle";
import { _converse, api, converse } from "@converse/headless/core";
const { Strophe, $iq, dayjs } = converse.env;
const { NS } = Strophe;
const u = converse.env.utils;
export default {
/**
* 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: {
/**
* @typedef { module:converse-rsm~RSMQueryParameters } MAMFilterParameters
* Filter parameters which can be used to filter a MAM XEP-0313 archive
* @property { String } [end] - A date string in ISO-8601 format, before which messages should be returned. Implies backward paging.
* @property { String } [start] - A date string in ISO-8601 format, after which messages should be returned. Implies forward paging.
* @property { String } [with] - A JID against which to match messages, according to either their `to` or `from` attributes.
* An item in a MUC archive matches if the publisher of the item matches the JID.
* If `with` is omitted, all messages that match the rest of the query will be returned, regardless of to/from
* addresses of each message.
*/
/**
* The options that can be passed in to the { @link _converse.api.archive.query } method
* @typedef { module:converse-mam~MAMFilterParameters } ArchiveQueryOptions
* @property { Boolean } [groupchat=false] - Whether the MAM archive is for a groupchat.
*/
/**
* Query for archived messages.
*
* The options parameter can also be an instance of
* RSM to enable easy querying between results pages.
*
* @method _converse.api.archive.query
* @param { module:converse-mam~ArchiveQueryOptions } options - An object containing query parameters
* @throws {Error} An error is thrown if the XMPP server responds with an error.
* @returns { Promise<module:converse-mam~MAMQueryResult> } A promise which resolves
* to a { @link module:converse-mam~MAMQueryResult } object.
*
* @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.
*
* let result;
* try {
* result = await api.archive.query();
* } catch (e) {
* // 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.
* }
* // Do something with the messages, like showing them in your webpage.
* result.messages.forEach(m => this.showMessage(m));
*
* @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
* let result;
* try {
* result = await api.archive.query({'with': 'john@doe.net'});
* } catch (e) {
* // The query was not successful
* }
*
* // For a particular room
* let result;
* try {
* result = await api.archive.query({'with': 'discuss@conference.doglovers.net', 'groupchat': true});
* } catch (e) {
* // The query was not successful
* }
*
* @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'
* };
* let result;
* try {
* result = await api.archive.query(options);
* } catch (e) {
* // The query was not successful
* }
*
* @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
* let result;
* try {
* result = await api.archive.query({'with': 'john@doe.net', 'max':10});
* } catch (e) {
* // The query was not successful
* }
*
* @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 options = {'with': 'john@doe.net', 'max':10};
* let result;
* try {
* result = await api.archive.query(options);
* } catch (e) {
* // The query was not successful
* }
* // Do something with the messages, like showing them in your webpage.
* result.messages.forEach(m => this.showMessage(m));
*
* while (!result.complete) {
* try {
* result = await api.archive.query(Object.assign(options, rsm.next(10).query));
* } catch (e) {
* // The query was not successful
* }
* // Do something with the messages, like showing them in your webpage.
* result.messages.forEach(m => this.showMessage(m));
* }
*
* @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 `''`.
*
* let result;
* const options = {'before': '', 'max':5};
* try {
* result = await api.archive.query(options);
* } catch (e) {
* // The query was not successful
* }
* // Do something with the messages, like showing them in your webpage.
* result.messages.forEach(m => this.showMessage(m));
*
* // Now we query again, to get the previous batch.
* try {
* result = await api.archive.query(Object.assign(options, rsm.previous(5).query));
* } catch (e) {
* // The query was not successful
* }
* // Do something with the messages, like showing them in your webpage.
* result.messages.forEach(m => this.showMessage(m));
*
*/
async query (options) {
if (!api.connection.connected()) {
throw new Error('Can\'t call `api.archive.query` before having established an XMPP session');
}
const attrs = {'type':'set'};
if (options && options.groupchat) {
if (!options['with']) {
throw new Error(
'You need to specify a "with" value containing '+
'the chat room JID, when querying groupchat messages.');
}
attrs.to = options['with'];
}
const jid = attrs.to || _converse.bare_jid;
const supported = await api.disco.supports(NS.MAM, jid);
if (!supported) {
log.warn(`Did not fetch MAM archive for ${jid} because it doesn't support ${NS.MAM}`);
return {'messages': []};
}
const queryid = u.getUniqueId();
const stanza = $iq(attrs).c('query', {'xmlns':NS.MAM, 'queryid':queryid});
if (options) {
stanza.c('x', {'xmlns':NS.XFORM, 'type': 'submit'})
.c('field', {'var':'FORM_TYPE', 'type': 'hidden'})
.c('value').t(NS.MAM).up().up();
if (options['with'] && !options.groupchat) {
stanza.c('field', {'var':'with'}).c('value')
.t(options['with']).up().up();
}
['start', 'end'].forEach(t => {
if (options[t]) {
const date = dayjs(options[t]);
if (date.isValid()) {
stanza.c('field', {'var':t}).c('value').t(date.toISOString()).up().up();
} else {
throw new TypeError(`archive.query: invalid date provided for: ${t}`);
}
}
});
stanza.up();
const rsm = new RSM(options);
if (Object.keys(rsm.query).length) {
stanza.cnode(rsm.toXML());
}
}
const messages = [];
const message_handler = _converse.connection.addHandler(stanza => {
const result = sizzle(`message > result[xmlns="${NS.MAM}"]`, stanza).pop();
if (result === undefined || result.getAttribute('queryid') !== queryid) {
return true;
}
const from = stanza.getAttribute('from') || _converse.bare_jid;
if (options.groupchat) {
if (from !== options['with']) {
log.warn(`Ignoring alleged groupchat MAM message from ${stanza.getAttribute('from')}`);
return true;
}
} else if (from !== _converse.bare_jid) {
log.warn(`Ignoring alleged MAM message from ${stanza.getAttribute('from')}`);
return true;
}
messages.push(stanza);
return true;
}, NS.MAM);
let error;
const iq_result = await api.sendIQ(stanza, api.settings.get('message_archiving_timeout'), false)
if (iq_result === null) {
const { __ } = _converse;
const err_msg = __("Timeout while trying to fetch archived messages.");
log.error(err_msg);
error = new _converse.TimeoutError(err_msg);
return { messages, error };
} else if (u.isErrorStanza(iq_result)) {
const { __ } = _converse;
const err_msg = __('An error occurred while querying for archived messages.');
log.error(err_msg);
log.error(iq_result);
error = new Error(err_msg);
return { messages, error };
}
_converse.connection.deleteHandler(message_handler);
let rsm;
const fin = iq_result && sizzle(`fin[xmlns="${NS.MAM}"]`, iq_result).pop();
const complete = fin?.getAttribute('complete') === 'true'
const set = sizzle(`set[xmlns="${NS.RSM}"]`, fin).pop();
if (set) {
rsm = new RSM({...options, 'xml': set});
}
/**
* @typedef { Object } MAMQueryResult
* @property { Array } messages
* @property { RSM } [rsm] - An instance of { @link RSM }.
* You can call `next()` or `previous()` on this instance,
* to get the RSM query parameters for the next or previous
* page in the result set.
* @property { Boolean } complete
* @property { Error } [error]
*/
return { messages, rsm, complete };
}
}
}
/**
* @description XEP-0313 Message Archive Management
* @copyright 2020, the Converse.js contributors
* @license Mozilla Public License (MPLv2)
*/
import mam_api from './api.js';
import '../disco';
import {
onMAMError,
onMAMPreferences,
getMAMPrefsFromFeature,
preMUCJoinMAMFetch,
fetchNewestMessages,
handleMAMResult
} from './utils.js';
import { _converse, api, converse } from '@converse/headless/core';
const { Strophe } = converse.env;
const { NS } = Strophe;
converse.plugins.add('converse-mam', {
dependencies: ['converse-disco', 'converse-muc'],
initialize () {
api.settings.extend({
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
});
Object.assign(api, mam_api);
// This is mainly done to aid with tests
Object.assign(_converse, { onMAMError, onMAMPreferences, handleMAMResult });
/************************ BEGIN Event Handlers ************************/
api.listen.on('addClientFeatures', () => api.disco.own.features.add(NS.MAM));
api.listen.on('serviceDiscovered', getMAMPrefsFromFeature);
api.listen.on('chatRoomViewInitialized', view => {
if (api.settings.get('muc_show_logs_before_join')) {
preMUCJoinMAMFetch(view.model);
// If we want to show MAM logs before entering the MUC, we need
// to be informed once it's clear that this MUC supports MAM.
view.model.features.on('change:mam_enabled', () => preMUCJoinMAMFetch(view.model));
}
});
api.listen.on('enteredNewRoom', muc => muc.features.get('mam_enabled') && fetchNewestMessages(muc));
api.listen.on('chatReconnected', chat => {
// XXX: For MUCs, we listen to enteredNewRoom instead
if (chat.get('type') === _converse.PRIVATE_CHAT_TYPE) {
fetchNewestMessages(chat);
}
});
api.listen.on('afterMessagesFetched', chat => {
// XXX: We don't want to query MAM every time this is triggered
// since it's not necessary when the chat is restored from cache.
// (given that BOSH or SMACKS will ensure that you get messages
// sent during the reload).
// With MUCs we can listen for `enteredNewRoom`.
if (chat.get('type') === _converse.PRIVATE_CHAT_TYPE && !_converse.connection.restored) {
fetchNewestMessages(chat);
}
});
/************************ END Event Handlers **************************/
}
});
import log from '@converse/headless/log';
import sizzle from 'sizzle';
import { parseMUCMessage } from '@converse/headless/plugins/muc/parsers';
import { parseMessage } from '@converse/headless/plugins/chat/parsers';
import { _converse, api, converse } from '@converse/headless/core';
const { Strophe, $iq } = converse.env;
const { NS } = Strophe;
const u = converse.env.utils;
export function onMAMError (iq) {
if (iq?.querySelectorAll('feature-not-implemented').length) {
log.warn(`Message Archive Management (XEP-0313) not supported by ${iq.getAttribute('from')}`);
} else {
log.error(`Error while trying to set archiving preferences for ${iq.getAttribute('from')}.`);
log.error(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.
*/
export function onMAMPreferences (iq, feature) {
const preference = sizzle(`prefs[xmlns="${NS.MAM}"]`, iq).pop();
const default_pref = preference.getAttribute('default');
if (default_pref !== api.settings.get('message_archiving')) {
const stanza = $iq({ 'type': 'set' }).c('prefs', {
'xmlns': NS.MAM,
'default': api.settings.get('message_archiving')
});
Array.from(preference.children).forEach(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.
api.sendIQ(stanza)
.then(() => feature.save({ 'preferences': { 'default': api.settings.get('message_archiving') } }))
.catch(_converse.onMAMError);
} else {
feature.save({ 'preferences': { 'default': api.settings.get('message_archiving') } });
}
}
export function getMAMPrefsFromFeature (feature) {
const prefs = feature.get('preferences') || {};
if (feature.get('var') !== NS.MAM || api.settings.get('message_archiving') === undefined) {
return;
}
if (prefs['default'] !== api.settings.get('message_archiving')) {
api.sendIQ($iq({ 'type': 'get' }).c('prefs', { 'xmlns': NS.MAM }))
.then(iq => _converse.onMAMPreferences(iq, feature))
.catch(_converse.onMAMError);
}
}
export function preMUCJoinMAMFetch (muc) {
if (
!api.settings.get('muc_show_logs_before_join') ||
!muc.features.get('mam_enabled') ||
muc.get('prejoin_mam_fetched')
) {
return;
}
fetchNewestMessages(muc);
muc.save({ 'prejoin_mam_fetched': true });
}
export async function handleMAMResult (model, result, query, options, page_direction) {
await api.emojis.initialize();
const is_muc = model.get('type') === _converse.CHATROOMS_TYPE;
result.messages = result.messages.map(s =>
is_muc ? parseMUCMessage(s, model, _converse) : parseMessage(s, _converse)
);
/**
* Synchronous event which allows listeners to first do some
* work based on the MAM result before calling the handlers here.
* @event _converse#MAMResult
*/
const data = { query, 'chatbox': model, 'messages': result.messages };
await api.trigger('MAMResult', data, { 'synchronous': true });
result.messages.forEach(m => model.queueMessage(m));
if (result.error) {
const event_id = (result.error.retry_event_id = u.getUniqueId());
api.listen.once(event_id, () => fetchArchivedMessages(model, options, page_direction));
model.createMessageFromError(result.error);
}
}
/**
* Fetch XEP-0313 archived messages based on the passed in criteria.
* @param { Object } options
* @param { integer } [options.max] - The maximum number of items to return.
* Defaults to "archived_messages_page_size"
* @param { string } [options.after] - The XEP-0359 stanza ID of a message
* after which messages should be returned. Implies forward paging.
* @param { string } [options.before] - The XEP-0359 stanza ID of a message
* before which messages should be returned. Implies backward paging.
* @param { string } [options.end] - A date string in ISO-8601 format,
* before which messages should be returned. Implies backward paging.
* @param { string } [options.start] - A date string in ISO-8601 format,
* after which messages should be returned. Implies forward paging.
* @param { string } [options.with] - The JID of the entity with
* which messages were exchanged.
* @param { boolean } [options.groupchat] - True if archive in groupchat.
* @param { ('forwards'|'backwards')} [page_direction] - Determines whether this function should
* recursively page through the entire result set if a limited number of results were returned.
*/
export async function fetchArchivedMessages (model, options = {}, page_direction) {
if (model.disable_mam) {
return;
}
const is_muc = model.get('type') === _converse.CHATROOMS_TYPE;
const mam_jid = is_muc ? model.get('jid') : _converse.bare_jid;
if (!(await api.disco.supports(NS.MAM, mam_jid))) {
return;
}
const max = api.settings.get('archived_messages_page_size');
const query = Object.assign(
{
'groupchat': is_muc,
'max': max,
'with': model.get('jid')
},
options
);
const result = await api.archive.query(query);
await handleMAMResult(model, result, query, options, page_direction);
if (page_direction && result.rsm && !result.complete) {
if (page_direction === 'forwards') {
options = result.rsm.next(max, options.before).query;
} else if (page_direction === 'backwards') {
options = result.rsm.previous(max, options.after).query;
}
return fetchArchivedMessages(model, options, page_direction);
} else {
// TODO: Add a special kind of message which will
// render as a link to fetch further messages, either
// to fetch older messages or to fill in a gap.
}
}
/**
* Fetches messages that might have been archived *after*
* the last archived message in our local cache.
*/
export function fetchNewestMessages (model) {
if (model.disable_mam) {
return;
}
const most_recent_msg = model.getMostRecentMessage();
// if clear_messages_on_reconnection is true, than any recent messages
// must have been received *after* connection and we instead must query
// for earlier messages
if (most_recent_msg && !api.settings.get('clear_messages_on_reconnection')) {
const stanza_id = most_recent_msg.get(`stanza_id ${model.get('jid')}`);
if (stanza_id) {
fetchArchivedMessages(model, { 'after': stanza_id }, 'forwards');
} else {
fetchArchivedMessages(model, { 'start': most_recent_msg.get('time') }, 'forwards');
}
} else {
fetchArchivedMessages(model, { 'before': '' });
}
}
/**
* @module converse-mam-views
* @description
* Views for XEP-0313 Message Archive Management
* @description UI code XEP-0313 Message Archive Management
* @copyright 2020, the Converse.js contributors
* @license Mozilla Public License (MPLv2)
*/
import { _converse, api, converse } from "@converse/headless/core";
import { fetchArchivedMessages } from '@converse/headless/plugins/mam/utils';
import { _converse, api, converse } from '@converse/headless/core';
async function fetchMessagesOnScrollUp (view) {
if (view.model.messages.length) {
const is_groupchat = view.model.get('type') === _converse.CHATROOMS_TYPE;
const oldest_message = view.model.getOldestMessage();
if (oldest_message) {
const by_jid = is_groupchat ? view.model.get('jid') : _converse.bare_jid;
const stanza_id = oldest_message && oldest_message.get(`stanza_id ${by_jid}`);
view.addSpinner();
if (stanza_id) {
await fetchArchivedMessages(view.model, { 'before': stanza_id });
} else {
await fetchArchivedMessages(view.model, { 'end': oldest_message.get('time') });
}
view.clearSpinner();
_converse.router.history.navigate(`#${oldest_message.get('msgid')}`);
}
}
}
converse.plugins.add('converse-mam-views', {
dependencies: ['converse-mam', 'converse-chatview', 'converse-muc-views'],
initialize () {
api.listen.on('chatBoxScrolledUp', async view => {
if (view.model.messages.length) {
const is_groupchat = view.model.get('type') === _converse.CHATROOMS_TYPE;
const oldest_message = view.model.getOldestMessage();
if (oldest_message) {
const by_jid = is_groupchat ? view.model.get('jid') : _converse.bare_jid;
const stanza_id = oldest_message && oldest_message.get(`stanza_id ${by_jid}`);
view.addSpinner();
if (stanza_id) {
await view.model.fetchArchivedMessages({'before': stanza_id});
} else {
await view.model.fetchArchivedMessages({'end': oldest_message.get('time')});
}
view.clearSpinner();
_converse.router.history.navigate(`#${oldest_message.get('msgid')}`);
}
}
});
api.listen.on('chatBoxScrolledUp', fetchMessagesOnScrollUp);
}
});
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