converse.js
Version:
Browser based XMPP chat client
508 lines (464 loc) • 19.2 kB
JavaScript
import { Collection, Model } from '@converse/skeletor';
import RosterContact from './contact.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 { initStorage } from '../../utils/storage.js';
import { rejectPresenceSubscription } from './utils.js';
const { Strophe, sizzle, stx, u, Stanza } = converse.env;
class RosterContacts extends Collection {
constructor() {
super();
this.model = RosterContact;
this.data = null;
}
initialize() {
const bare_jid = _converse.session.get('bare_jid');
const id = `roster.state-${bare_jid}-${this.get('jid')}`;
this.state = new Model({ id, 'collapsed_groups': [] });
initStorage(this.state, id);
this.state.fetch();
api.listen.on(
'chatBoxClosed',
/** @param {import('../../shared/chatbox').default} model */
(model) => this.removeUnsavedContact(model)
);
}
/**
* @param {import('../../shared/chatbox').default} model
*/
removeUnsavedContact(model) {
const contact = this.get(model.get('jid'));
if (contact && contact.get('subscription') === undefined) {
contact.destroy();
}
}
onConnected() {
// Called as soon as the connection has been established
// (either after initial login, or after reconnection).
// Use the opportunity to register stanza handlers.
this.registerRosterHandler();
this.registerRosterXHandler();
}
/**
* Register a handler for roster IQ "set" stanzas, which update
* roster contacts.
*/
registerRosterHandler() {
// Register a handler for roster IQ "set" stanzas, which update
// roster contacts.
api.connection.get().addHandler(
/** @param {Element} iq */ (iq) => {
_converse.state.roster.onRosterPush(iq);
return true;
},
Strophe.NS.ROSTER,
'iq',
'set'
);
}
/**
* Register a handler for RosterX message stanzas, which are
* used to suggest roster contacts to a user.
*/
registerRosterXHandler() {
let t = 0;
const connection = api.connection.get();
connection.addHandler(
/** @param {Element} msg */ (msg) => {
setTimeout(() => {
const { roster } = _converse.state;
api.connection.get().flush();
roster.subscribeToSuggestedItems(msg);
}, t);
t += msg.querySelectorAll('item').length * 250;
return true;
},
Strophe.NS.ROSTERX,
'message',
null
);
}
/**
* Fetches the roster contacts, first by trying the browser cache,
* and if that's empty, then by querying the XMPP server.
* @returns {promise} Promise which resolves once the contacts have been fetched.
*/
async fetchRosterContacts() {
const result = await new Promise((resolve, reject) => {
this.fetch({
add: true,
silent: true,
success: resolve,
error: (_, e) => reject(e),
});
});
if (u.isErrorObject(result)) {
log.error(result);
// Force a full roster refresh
_converse.session.save('roster_cached', false);
this.data.save('version', undefined);
}
if (_converse.session.get('roster_cached')) {
/**
* The contacts roster has been retrieved from the local cache (`sessionStorage`).
* @event _converse#cachedRoster
* @type {RosterContacts}
* @example _converse.api.listen.on('cachedRoster', (items) => { ... });
* @example _converse.api.waitUntil('cachedRoster').then(items => { ... });
*/
api.trigger('cachedRoster', result);
} else {
api.connection.get().send_initial_presence = true;
return _converse.state.roster.fetchFromServer();
}
}
/**
* @param {Element} msg
*/
subscribeToSuggestedItems(msg) {
Array.from(msg.querySelectorAll('item')).forEach((item) => {
if (item.getAttribute('action') === 'add') {
this.addContact({
jid: item.getAttribute('jid'),
name: item.getAttribute('name'),
subscription: 'to',
});
}
});
return true;
}
/**
* @param {string} jid
*/
isSelf(jid) {
return u.isSameBareJID(jid, api.connection.get().jid);
}
/**
* Send an IQ stanza to the XMPP server to add a new roster contact.
* @param {import('./types').RosterContactAttributes} attributes
*/
sendContactAddIQ(attributes) {
const { jid, groups } = attributes;
const name = attributes.name ? attributes.name : null;
const iq = stx`
<iq type="set" xmlns="jabber:client">
<query xmlns="${Strophe.NS.ROSTER}">
<item jid="${jid}" ${name ? Stanza.unsafeXML(`name="${Strophe.xmlescape(name)}"`) : ''}>
${groups?.map(/** @param {string} g */ (g) => stx`<group>${g}</group>`)}
</item>
</query>
</iq>`;
return api.sendIQ(iq);
}
/**
* Adds a {@link RosterContact} instance to {@link RosterContacts} and
* optionally (if subscribe=true) subscribe to the contact's presence
* updates which also adds the contact to the roster on the XMPP server.
* @param {import('./types').RosterContactAttributes} attributes
* @param {boolean} [persist=true] - Whether the contact should be persisted to the user's roster.
* @param {boolean} [subscribe=true] - Whether we should subscribe to the contacts presence updates.
* @param {string} [message=''] - An optional message to include with the presence subscription
* @returns {Promise<RosterContact>}
*/
async addContact(attributes, persist = true, subscribe = true, message = '') {
const { jid, name } = attributes ?? {};
if (!jid || !u.isValidJID(jid)) throw new Error('Invalid JID provided to addContact');
await api.waitUntil('rosterContactsFetched');
if (persist) {
try {
await this.sendContactAddIQ(attributes);
} catch (e) {
log.error(e);
const { __ } = _converse;
alert(__('Sorry, an error occurred while trying to add %1$s as a contact.', name || jid));
throw e;
}
}
const contact = await this.create(
{
...{
ask: undefined,
nickname: name,
groups: [],
requesting: false,
subscription: persist ? 'none' : undefined,
},
...attributes,
},
{ sort: false }
);
if (subscribe) contact.subscribe(message);
return contact;
}
/**
* @param {string} bare_jid
* @param {Element} presence
* @param {string} [auth_msg=''] - Optional message to be included in the
* authorization of the contacts subscription request.
* @param {string} [sub_msg=''] - Optional message to be included in our
* reciprocal subscription request.
*/
async subscribeBack(bare_jid, presence, auth_msg = '', sub_msg = '') {
const contact = this.get(bare_jid);
const { RosterContact } = _converse.exports;
if (contact instanceof RosterContact) {
contact.authorize().subscribe();
} else {
// Can happen when a subscription is retried or roster was deleted
const nickname = sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop()?.textContent || undefined;
const contact = await this.addContact({
jid: bare_jid,
name: nickname,
groups: [],
subscription: 'from',
});
if (contact instanceof RosterContact) {
contact.authorize(auth_msg).subscribe(sub_msg);
}
}
}
/**
* Handle roster updates from the XMPP server.
* See: https://xmpp.org/rfcs/rfc6121.html#roster-syntax-actions-push
* @param {Element} iq - The IQ stanza received from the XMPP server.
*/
onRosterPush(iq) {
const id = iq.getAttribute('id');
const from = iq.getAttribute('from');
const bare_jid = _converse.session.get('bare_jid');
if (from && from !== bare_jid) {
// https://tools.ietf.org/html/rfc6121#page-15
//
// A receiving client MUST ignore the stanza unless it has no 'from'
// attribute (i.e., implicitly from the bare JID of the user's
// account) or it has a 'from' attribute whose value matches the
// user's bare JID <user@domainpart>.
log.warn(`Ignoring roster illegitimate roster push message from ${iq.getAttribute('from')}`);
return;
}
api.send(stx`<iq type="result" id="${id}" from="${api.connection.get().jid}" xmlns="jabber:client" />`);
const query = sizzle(`query[xmlns="${Strophe.NS.ROSTER}"]`, iq).pop();
this.data.save('version', query.getAttribute('ver'));
const items = sizzle(`item`, query);
if (items.length > 1) {
log.error(iq);
throw new Error('Roster push query may not contain more than one "item" element.');
}
if (items.length === 0) {
log.warn(iq);
log.warn('Received a roster push stanza without an "item" element.');
return;
}
this.updateContact(items.pop());
/**
* When the roster receives a push event from server (i.e. new entry in your contacts roster).
* @event _converse#rosterPush
* @type {Element}
* @example _converse.api.listen.on('rosterPush', iq => { ... });
*/
api.trigger('rosterPush', iq);
return;
}
shouldUseRosterVersioning() {
return (
api.settings.get('enable_roster_versioning') &&
this.data.get('version') &&
api.disco.stream.getFeature('ver', 'urn:xmpp:features:rosterver')
);
}
/**
* Fetches the roster from the XMPP server and updates the local state
* @emits _converse#roster
* @returns {Promise}
*/
async fetchFromServer() {
const stanza = stx`
<iq type="get" id="${u.getUniqueId('roster')}" xmlns="jabber:client">
<query xmlns="${Strophe.NS.ROSTER}"
${this.shouldUseRosterVersioning() ? Stanza.unsafeXML(`ver="${this.data.get('version')}"`) : ''}>
</query>
</iq>`;
const iq = await api.sendIQ(stanza, null, false);
if (iq.getAttribute('type') === 'result') {
const query = sizzle(`query[xmlns="${Strophe.NS.ROSTER}"]`, iq).pop();
if (query) {
const items = sizzle(`item`, query);
if (!this.data.get('version') && this.models.length) {
// We're getting the full roster, so remove all cached
// contacts that aren't included in it.
const jids = items.map(/** @param {Element} item */ (item) => item.getAttribute('jid'));
this.forEach((m) => !m.get('requesting') && !jids.includes(m.get('jid')) && m.destroy());
}
items.forEach((item) => this.updateContact(item));
this.data.save('version', query.getAttribute('ver'));
}
} else if (!u.isServiceUnavailableError(iq)) {
// Some unknown error happened, so we will try to fetch again if the page reloads.
log.error(iq);
log.error('Error while trying to fetch roster from the server');
return;
}
_converse.session.save('roster_cached', true);
/**
* When the roster has been received from the XMPP server.
* See also the `cachedRoster` event further up, which gets called instead of
* `roster` if its already in `sessionStorage`.
* @event _converse#roster
* @type {Element}
* @example _converse.api.listen.on('roster', iq => { ... });
* @example _converse.api.waitUntil('roster').then(iq => { ... });
*/
api.trigger('roster', iq);
}
/**
* Update or create RosterContact models based on the given `item` XML
* node received in the resulting IQ stanza from the server.
* @param {Element} item
*/
updateContact(item) {
const jid = item.getAttribute('jid');
const contact = this.get(jid);
const subscription = item.getAttribute('subscription');
if (subscription === 'remove') {
return contact?.destroy();
}
const ask = item.getAttribute('ask');
const nickname = item.getAttribute('name');
const groups = [...new Set(sizzle('group', item).map((e) => e.textContent))];
if (contact) {
// We only find out about requesting contacts via the
// presence handler, so if we receive a contact
// here, we know they aren't requesting anymore.
contact.save({ subscription, ask, nickname, groups, 'requesting': null });
} else {
this.create({ nickname, ask, groups, jid, subscription }, { sort: false });
}
}
/**
* @param {Element} presence
*/
createRequestingContact(presence) {
const jid = Strophe.getBareJidFromJid(presence.getAttribute('from'));
const nickname = sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop()?.textContent || null;
const user_data = {
jid,
subscription: 'none',
ask: null,
requesting: true,
nickname: nickname,
};
/**
* Triggered when someone has requested to subscribe to your presence (i.e. to be your contact).
* @event _converse#contactRequest
* @type {RosterContact}
* @example _converse.api.listen.on('contactRequest', contact => { ... });
*/
api.trigger('contactRequest', this.create(user_data));
}
/**
* @param {Element} presence
*/
handleIncomingSubscription(presence) {
const jid = presence.getAttribute('from'),
bare_jid = Strophe.getBareJidFromJid(jid),
contact = this.get(bare_jid);
if (!api.settings.get('allow_contact_requests')) {
const { __ } = _converse;
rejectPresenceSubscription(jid, __('This client does not allow presence subscriptions'));
}
if (api.settings.get('auto_subscribe')) {
if (!contact || contact.get('subscription') !== 'to') {
this.subscribeBack(bare_jid, presence);
} else {
contact.authorize();
}
} else {
if (contact) {
if (contact.get('subscription') !== 'none') {
contact.authorize();
} else if (contact.get('ask') === 'subscribe') {
contact.authorize();
}
} else {
this.createRequestingContact(presence);
}
}
}
/**
* @param {Element} stanza
*/
handleOwnPresence(stanza) {
const jid = stanza.getAttribute('from');
const resource = Strophe.getResourceFromJid(jid);
const presence_type = stanza.getAttribute('type');
const { profile } = _converse.state;
if (
api.connection.get().jid !== jid &&
presence_type !== 'unavailable' &&
(api.settings.get('synchronize_availability') === true ||
api.settings.get('synchronize_availability') === resource)
) {
// Another resource has changed its status and
// synchronize_availability option set to update,
// we'll update ours as well.
const show = stanza.querySelector('show')?.textContent;
profile.save({ show, presence: 'online' }, { silent: true });
const status_message = stanza.querySelector('status')?.textContent;
if (status_message) profile.save({ status_message });
}
if (_converse.session.get('jid') === jid && presence_type === 'unavailable') {
// XXX: We've received an "unavailable" presence from our
// own resource. Apparently this happens due to a
// Prosody bug, whereby we send an IQ stanza to remove
// a roster contact, and Prosody then sends
// "unavailable" globally, instead of directed to the
// particular user that's removed.
//
// Here is the bug report: https://prosody.im/issues/1121
//
// I'm not sure whether this might legitimately happen
// in other cases.
//
// As a workaround for now we simply send our presence again,
// otherwise we're treated as offline.
api.user.presence.send();
}
}
/**
* @param {Element} presence
*/
presenceHandler(presence) {
const presence_type = presence.getAttribute('type');
if (presence_type === 'error') return true;
const jid = presence.getAttribute('from');
const bare_jid = Strophe.getBareJidFromJid(jid);
if (this.isSelf(bare_jid)) {
return this.handleOwnPresence(presence);
} else if (sizzle(`query[xmlns="${Strophe.NS.MUC}"]`, presence).length) {
return; // Ignore MUC
}
const contact = this.get(bare_jid);
if (contact) {
const status = presence.querySelector('status')?.textContent;
if (contact.get('status') !== status) contact.save({ status });
}
if (presence_type === 'subscribed' && contact) {
contact.ackSubscribe();
} else if (presence_type === 'unsubscribed' && contact) {
contact.ackUnsubscribe();
} else if (presence_type === 'unsubscribe') {
return;
} else if (presence_type === 'subscribe') {
this.handleIncomingSubscription(presence);
} else if (presence_type === 'unavailable' && contact) {
const resource = Strophe.getResourceFromJid(jid);
contact.presence.removeResource(resource);
} else if (contact) {
// presence_type is undefined
contact.presence.addResource(presence);
}
}
}
export default RosterContacts;