converse.js
Version:
Browser based XMPP chat client
281 lines (251 loc) • 10.3 kB
JavaScript
/**
* @typedef {import('../muc/muc').default} MUC
* @typedef {import('../chat/model').default} ChatBox
* @typedef {import('@converse/skeletor/src/types/helpers.js').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;
/**
* @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
* probbaly 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 = $iq({ "type": "get" }).c("prefs", { "xmlns": NS.MAM });
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;
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);
}
}