2021-01-26 14:29:11 +01:00
|
|
|
import RosterContact from './contact.js';
|
|
|
|
import log from "@converse/headless/log";
|
2021-05-12 12:14:28 +02:00
|
|
|
import sum from 'lodash-es/sum';
|
2021-01-26 14:29:11 +01:00
|
|
|
import { Collection } from "@converse/skeletor/src/collection";
|
2021-01-26 14:01:37 +01:00
|
|
|
import { Model } from "@converse/skeletor/src/model";
|
2021-01-26 14:29:11 +01:00
|
|
|
import { __ } from 'i18n';
|
|
|
|
import { _converse, api, converse } from "@converse/headless/core";
|
2021-07-15 18:45:16 +02:00
|
|
|
import { initStorage } from '@converse/headless/utils/storage.js';
|
2021-06-23 10:52:45 +02:00
|
|
|
import { rejectPresenceSubscription } from './utils.js';
|
2021-01-26 14:29:11 +01:00
|
|
|
|
2021-06-23 10:52:45 +02:00
|
|
|
const { Strophe, $iq, sizzle, u } = converse.env;
|
2021-01-26 14:29:11 +01:00
|
|
|
|
|
|
|
|
|
|
|
const RosterContacts = Collection.extend({
|
|
|
|
model: RosterContact,
|
|
|
|
|
2021-01-26 14:01:37 +01:00
|
|
|
initialize () {
|
|
|
|
const id = `roster.state-${_converse.bare_jid}-${this.get('jid')}`;
|
|
|
|
this.state = new Model({ id, 'collapsed_groups': [] });
|
2021-03-23 14:52:08 +01:00
|
|
|
initStorage(this.state, id);
|
2021-01-26 14:01:37 +01:00
|
|
|
this.state.fetch();
|
2021-01-26 14:29:11 +01:00
|
|
|
},
|
|
|
|
|
|
|
|
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.
|
|
|
|
* @private
|
|
|
|
* @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': (c, e) => reject(e)
|
|
|
|
});
|
|
|
|
});
|
|
|
|
if (u.isErrorObject(result)) {
|
|
|
|
log.error(result);
|
|
|
|
// Force a full roster refresh
|
2021-03-10 12:17:23 +01:00
|
|
|
_converse.session.save('roster_cached', false)
|
2021-01-26 14:29:11 +01:00
|
|
|
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.
|
|
|
|
* @private
|
|
|
|
* @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.
|
|
|
|
* @private
|
|
|
|
* @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
|
|
|
|
* @param { Function } callback - A function to call once the IQ is returned
|
|
|
|
* @param { Function } errback - A function to call if an error occurred
|
|
|
|
*/
|
|
|
|
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.
|
|
|
|
* @private
|
|
|
|
* @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) {
|
|
|
|
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();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
getNumOnlineContacts () {
|
|
|
|
const ignored = ['offline', 'unavailable'];
|
|
|
|
return sum(this.models.filter(m => !ignored.includes(m.presence.get('show'))));
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handle roster updates from the XMPP server.
|
|
|
|
* See: https://xmpp.org/rfcs/rfc6121.html#roster-syntax-actions-push
|
|
|
|
* @private
|
|
|
|
* @method _converse.RosterContacts#onRosterPush
|
|
|
|
* @param { XMLElement } 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 { XMLElement }
|
|
|
|
* @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
|
|
|
|
* @private
|
|
|
|
* @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') !== 'error') {
|
|
|
|
const query = sizzle(`query[xmlns="${Strophe.NS.ROSTER}"]`, iq).pop();
|
|
|
|
if (query) {
|
|
|
|
const items = sizzle(`item`, query);
|
|
|
|
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 { XMLElement }
|
|
|
|
* @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.
|
|
|
|
* @private
|
|
|
|
* @param { XMLElement } item
|
|
|
|
*/
|
|
|
|
updateContact (item) {
|
|
|
|
const jid = item.getAttribute('jid');
|
|
|
|
const contact = this.get(jid);
|
|
|
|
const subscription = item.getAttribute("subscription");
|
|
|
|
const ask = item.getAttribute("ask");
|
2021-06-23 11:14:02 +02:00
|
|
|
const groups = [...new Set(sizzle('group', item).map(e => e.textContent))];
|
2021-01-26 14:29:11 +01:00
|
|
|
if (!contact) {
|
|
|
|
if ((subscription === "none" && ask === null) || (subscription === "remove")) {
|
|
|
|
return; // We're lazy when adding contacts.
|
|
|
|
}
|
|
|
|
this.create({
|
|
|
|
'ask': ask,
|
|
|
|
'nickname': item.getAttribute("name"),
|
|
|
|
'groups': groups,
|
|
|
|
'jid': jid,
|
|
|
|
'subscription': subscription
|
|
|
|
}, {sort: false});
|
|
|
|
} else {
|
|
|
|
if (subscription === "remove") {
|
|
|
|
return contact.destroy();
|
|
|
|
}
|
|
|
|
// 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.
|
|
|
|
// see docs/DEVELOPER.rst
|
|
|
|
contact.save({
|
|
|
|
'subscription': subscription,
|
|
|
|
'ask': ask,
|
|
|
|
'nickname': item.getAttribute("name"),
|
|
|
|
'requesting': null,
|
|
|
|
'groups': groups
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
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')) {
|
2021-06-23 10:52:45 +02:00
|
|
|
rejectPresenceSubscription(
|
2021-01-26 14:29:11 +01:00
|
|
|
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': 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'),
|
|
|
|
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 status_message = presence.querySelector('status')?.textContent;
|
|
|
|
const contact = this.get(bare_jid);
|
|
|
|
|
|
|
|
if (contact && (status_message !== contact.get('status'))) {
|
|
|
|
contact.save({'status': status_message});
|
|
|
|
}
|
|
|
|
|
|
|
|
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;
|