UNPKG

@converse/headless

Version:

Converse.js Headless build

293 lines (262 loc) 10.6 kB
/** * @typedef {import('../muc/muc').default} MUC * @typedef {import('../chat/model').default} ChatBox * @typedef {import('@converse/skeletor').Model} Model */ import sizzle from 'sizzle'; import { Strophe, $iq } from 'strophe.js'; import _converse from '../../shared/_converse.js'; import api from '../../shared/api/index.js'; import converse from '../../shared/api/public.js'; import log from '@converse/log'; import { parseMUCMessage } from '../../plugins/muc/parsers.js'; import { parseMessage } from '../../plugins/chat/parsers.js'; import { CHATROOMS_TYPE } from '../../shared/constants.js'; import { TimeoutError } from '../../shared/errors.js'; import MAMPlaceholderMessage from './placeholder.js'; import { parseErrorStanza } from '../../shared/parsers.js'; const { NS } = Strophe; const u = converse.env.utils; const { stx } = converse.env; /** * @param {Element|Error} e * @param {Element} iq */ export async function onMAMError(e, iq) { if (u.isElement(e)) { const err = await parseErrorStanza(e); if (err?.name === 'feature-not-implemented') { log.warn(`Message Archive Management (XEP-0313) not supported by ${iq.getAttribute('to')}`); return; } } log.error(`Error while trying to set archiving preferences for ${iq.getAttribute('to')}.`); 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 * probably be handled elsewhere in any case. * * @param {Element} iq * @param {Model} feature */ 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(/** @param {Error|Element} e */ (e) => _converse.exports.onMAMError(e, stanza.tree())); } else { feature.save({ 'preferences': { 'default': api.settings.get('message_archiving') } }); } } /** * @param {Model} feature */ 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')) { const stanza = stx` <iq type="get" xmlns="jabber:client"> <prefs xmlns="${NS.MAM}"></prefs> </iq>`; api.sendIQ(stanza) .then(/** @param {Element} iq */ (iq) => _converse.exports.onMAMPreferences(iq, feature)) .catch(/** @param {Error|Element} e */ (e) => _converse.exports.onMAMError(e, stanza.tree())); } } /** * @param {MUC} muc */ 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 }); } /** * @param {ChatBox|MUC} model * @param {Error|string} error */ async function createMessageFromError(model, error) { if (error instanceof TimeoutError) { const msg = await model.createMessage({ type: 'error', message: error.message, retry_event_id: error.retry_event_id, is_ephemeral: 20000, }); msg.error = error; } } /** * @param {ChatBox|MUC} model * @param {Object} result * @param {Object} query * @param {Object} options * @param {('forwards'|'backwards'|false)} [should_page=false] */ export async function handleMAMResult(model, result, query, options, should_page = false) { const is_muc = model.get('type') === CHATROOMS_TYPE; const doParseMessage = /** @param {Element} s*/ (s) => is_muc ? parseMUCMessage(s, /** @type {MUC} */ (model)) : parseMessage(s); const messages = await Promise.all(result.messages.map(doParseMessage)); result.messages = messages; /** * 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 }; await api.trigger('MAMResult', data, { 'synchronous': true }); 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, should_page)); createMessageFromError(model, result.error); } } /** * Fetch XEP-0313 archived messages based on the passed in criteria. * @param {ChatBox|MUC} model * @param {import('./types').FetchArchivedMessagesOptions} [options] * @param {('forwards'|'backwards'|false)} [should_page=false] - 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 = {}, should_page = false) { if (model.disable_mam) return; const is_muc = model.get('type') === CHATROOMS_TYPE; const bare_jid = _converse.session.get('bare_jid'); const mam_jid = is_muc ? model.get('jid') : bare_jid; const supported = await api.disco.supports(NS.MAM, mam_jid); if (!supported) return; // The session may have been torn down while we were awaiting above. If so, // there's no point querying the archive (and `api.archive.query` would throw). if (!api.connection.connected()) return; const max = api.settings.get('archived_messages_page_size'); const query = /** @type {import('./types').ArchiveQueryOptions} */ { is_groupchat: is_muc, rsm: { max, ...options.rsm, }, mam: { with: model.get('jid'), ...options.mam, }, }; const result = await api.archive.query(query); await handleMAMResult(model, result, query, options, should_page); if (result.rsm && !result.complete) { if (should_page) { if (should_page === 'forwards') { options = result.rsm.next(max, options.rsm.before).query; } else if (should_page === 'backwards') { options = result.rsm.previous(max, options.rsm.after).query; } return fetchArchivedMessages(model, options, should_page); } else { createGapPlaceholder(model, options, result); } } } /** * Creates a placeholder to fill gaps in the history. * @param {ChatBox|MUC} model * @param {import('./types').ArchiveQueryOptions} [options] * @param {import('./types').MAMQueryResult} [result] */ async function createGapPlaceholder(model, options, result) { const msgs = await Promise.all(result.messages); const is_muc = model.get('type') === CHATROOMS_TYPE; const mam_jid = is_muc ? model.get('jid') : _converse.session.get('bare_jid'); const { rsm } = result; const key = `stanza_id ${mam_jid}`; const adjacent_message = msgs.find((m) => m[key] === rsm.result.first); const adjacent_message_date = new Date(adjacent_message['time']); const msg_data = { before: rsm.result.first, start: options.mam?.start, template_hook: 'getMessageTemplate', time: new Date(adjacent_message_date.getTime() - 1).toISOString(), }; if (model.messages.findWhere(msg_data)) { log.debug('Gap placeholder already exists, not recreating.'); return; } model.messages.add(new MAMPlaceholderMessage(msg_data)); } /** * Creates a placeholder to fetch messages at the top of the chat history. * @param {ChatBox|MUC} model */ export function createScrollupPlaceholder(model) { if (model.messages.length) { const is_muc = model.get('type') === CHATROOMS_TYPE; const mam_jid = is_muc ? model.get('jid') : _converse.session.get('bare_jid'); const key = `stanza_id ${mam_jid}`; const oldest_message = model.getOldestMessage(); if (!oldest_message) return; const msg_data = { before: oldest_message.get(key), template_hook: 'getMessageTemplate', time: new Date(new Date(oldest_message.get('time')).getTime() - 1).toISOString(), }; if (model.messages.findWhere(msg_data)) { log.debug('Gap placeholder already exists, not recreating.'); return; } model.messages.add(new MAMPlaceholderMessage(msg_data)); } } /** * Fetches messages that might have been archived *after* * the last archived message in our local cache. * @param {ChatBox|MUC} model */ export function fetchNewestMessages(model) { if (model.disable_mam) return; // XXX: It's important to first get the most recent message, before making any // async calls, like `api.disco.supports`, so that we can avoid a race // condition with possible new incoming messages. // We want the most recent cached message, otherwise we would query with // the wrong `start` value. This function used to call // `api.disco.supports`, but it's also called in `fetchArchivedMessages`, // so it's not necessary here. const most_recent_msg = model.getMostRecentMessage(); const should_page = api.settings.get('mam_request_all_pages') ? 'backwards' : false; if (most_recent_msg) { return fetchArchivedMessages( model, { mam: { start: most_recent_msg.get('time') }, rsm: { before: '' } }, should_page, ); } else { return fetchArchivedMessages(model, { rsm: { before: '' } }, should_page); } }