import RosterContact from './contact.js';
import log from "@converse/headless/log";
import { Collection } from "@converse/skeletor/src/collection";
import { Model } from "@converse/skeletor/src/model";
import { _converse, api, converse } from "@converse/headless/core";
import { initStorage } from '@converse/headless/utils/storage.js';
import { rejectPresenceSubscription } from './utils.js';
const { Strophe, $iq, sizzle, u } = converse.env;
const RosterContacts = Collection.extend({
model: RosterContact,
initialize () {
const id = `roster.state-${_converse.bare_jid}-${this.get('jid')}`;
this.state = new Model({ id, 'collapsed_groups': [] });
initStorage(this.state, id);
this.state.fetch();
},
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();
},
registerRosterHandler () {
// Register a handler for roster IQ "set" stanzas, which update
// roster contacts.
_converse.connection.addHandler(iq => {
_converse.roster.onRosterPush(iq);
return true;
}, Strophe.NS.ROSTER, 'iq', "set");
},
registerRosterXHandler () {
// Register a handler for RosterX message stanzas, which are
// used to suggest roster contacts to a user.
let t = 0;
_converse.connection.addHandler(
function (msg) {
window.setTimeout(
function () {
_converse.connection.flush();
_converse.roster.subscribeToSuggestedItems.bind(_converse.roster)(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 { _converse.RosterContacts }
* @example _converse.api.listen.on('cachedRoster', (items) => { ... });
* @example _converse.api.waitUntil('cachedRoster').then(items => { ... });
*/
api.trigger('cachedRoster', result);
} else {
_converse.send_initial_presence = true;
return _converse.roster.fetchFromServer();
}
},
subscribeToSuggestedItems (msg) {
Array.from(msg.querySelectorAll('item')).forEach(item => {
if (item.getAttribute('action') === 'add') {
_converse.roster.addAndSubscribe(
item.getAttribute('jid'),
_converse.xmppstatus.getNickname() || _converse.xmppstatus.getFullname()
);
}
});
return true;
},
isSelf (jid) {
return u.isSameBareJID(jid, _converse.connection.jid);
},
/**
* Add a roster contact and then once we have confirmation from
* the XMPP server we subscribe to that contact's presence updates.
* @method _converse.RosterContacts#addAndSubscribe
* @param { String } jid - The Jabber ID of the user being added and subscribed to.
* @param { String } name - The name of that user
* @param { Array<String> } groups - Any roster groups the user might belong to
* @param { String } message - An optional message to explain the reason for the subscription request.
* @param { Object } attributes - Any additional attributes to be stored on the user's model.
*/
async addAndSubscribe (jid, name, groups, message, attributes) {
const contact = await this.addContactToRoster(jid, name, groups, attributes);
if (contact instanceof _converse.RosterContact) {
contact.subscribe(message);
}
},
/**
* Send an IQ stanza to the XMPP server to add a new roster contact.
* @method _converse.RosterContacts#sendContactAddIQ
* @param { String } jid - The Jabber ID of the user being added
* @param { String } name - The name of that user
* @param { Array<String> } groups - Any roster groups the user might belong to
*/
sendContactAddIQ (jid, name, groups) {
name = name ? name : null;
const iq = $iq({'type': 'set'})
.c('query', {'xmlns': Strophe.NS.ROSTER})
.c('item', { jid, name });
groups.forEach(g => iq.c('group').t(g).up());
return api.sendIQ(iq);
},
/**
* Adds a RosterContact instance to _converse.roster and
* registers the contact on the XMPP server.
* Returns a promise which is resolved once the XMPP server has responded.
* @method _converse.RosterContacts#addContactToRoster
* @param { String } jid - The Jabber ID of the user being added and subscribed to.
* @param { String } name - The name of that user
* @param { Array<String> } groups - Any roster groups the user might belong to
* @param { Object } attributes - Any additional attributes to be stored on the user's model.
*/
async addContactToRoster (jid, name, groups, attributes) {
await api.waitUntil('rosterContactsFetched');
groups = groups || [];
try {
await this.sendContactAddIQ(jid, name, groups);
} catch (e) {
const { __ } = _converse;
log.error(e);
alert(__('Sorry, there was an error while trying to add %1$s as a contact.', name || jid));
return e;
}
return this.create(Object.assign({
'ask': undefined,
'nickname': name,
groups,
jid,
'requesting': false,
'subscription': 'none'
}, attributes), {'sort': false});
},
async subscribeBack (bare_jid, presence) {
const contact = this.get(bare_jid);
if (contact instanceof _converse.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 || null;
const contact = await this.addContactToRoster(bare_jid, nickname, [], {'subscription': 'from'});
if (contact instanceof _converse.RosterContact) {
contact.authorize().subscribe();
}
}
},
/**
* Handle roster updates from the XMPP server.
* See: https://xmpp.org/rfcs/rfc6121.html#roster-syntax-actions-push
* @method _converse.RosterContacts#onRosterPush
* @param { Element } iq - The IQ stanza received from the XMPP server.
*/
onRosterPush (iq) {
const id = iq.getAttribute('id');
const from = iq.getAttribute('from');
if (from && from !== _converse.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($iq({type: 'result', id, from: _converse.connection.jid}));
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;
},
rosterVersioningSupported () {
return api.disco.stream.getFeature('ver', 'urn:xmpp:features:rosterver') && this.data.get('version');
},
/**
* Fetch the roster from the XMPP server
* @emits _converse#roster
* @returns {promise}
*/
async fetchFromServer () {
const stanza = $iq({
'type': 'get',
'id': u.getUniqueId('roster')
}).c('query', {xmlns: Strophe.NS.ROSTER});
if (this.rosterVersioningSupported()) {
stanza.attrs({'ver': this.data.get('version')});
}
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(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});
}
},
createRequestingContact (presence) {
const bare_jid = Strophe.getBareJidFromJid(presence.getAttribute('from'));
const nickname = sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop()?.textContent || null;
const user_data = {
'jid': bare_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 { _converse.RosterContact }
* @example _converse.api.listen.on('contactRequest', contact => { ... });
*/
api.trigger('contactRequest', this.create(user_data));
},
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);
}
}
},
handleOwnPresence (presence) {
const jid = presence.getAttribute('from'),
resource = Strophe.getResourceFromJid(jid),
presence_type = presence.getAttribute('type');
if ((_converse.connection.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 = presence.querySelector('show')?.textContent || 'online';
_converse.xmppstatus.save({'status': show}, {'silent': true});
const status_message = presence.querySelector('status')?.textContent;
if (status_message) _converse.xmppstatus.save({ status_message });
}
if (_converse.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();
}
},
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;