converse.js
Version:
Browser based XMPP chat client
404 lines (365 loc) • 13.5 kB
JavaScript
/**
* @typedef {import('@converse/skeletor').Model} Model
* @typedef {import('@converse/headless').RosterContact} RosterContact
* @typedef {import('@converse/headless').RosterContacts} RosterContacts
*/
import { __ } from 'i18n';
import { _converse, api, converse, log, constants, u, Profile } from '@converse/headless';
const { Strophe } = converse.env;
const { STATUS_WEIGHTS } = constants;
/**
* @param {RosterContact} contact
* @param {boolean} [unauthorize]
* @returns {Promise<boolean>}
*/
export async function removeContact(contact, unauthorize = false) {
if (!api.settings.get('allow_contact_removal')) return;
const result = await api.confirm(__('Confirm'), __('Are you sure you want to remove this contact?'));
if (!result) return false;
const chat = await api.chats.get(contact.get('jid'));
chat?.close();
try {
await contact.remove(unauthorize);
} catch (e) {
log.error(e);
api.alert('error', __('Error'), [
__('Sorry, an error occurred while trying to remove %1$s as a contact', contact.getDisplayName()),
]);
}
return true;
}
/**
* @param {RosterContact} contact
*/
export async function declineContactRequest(contact) {
const domain = _converse.session.get('domain');
const blocking_supported = await api.disco.supports(Strophe.NS.BLOCKING, domain);
const result = await api.confirm(
__('Remove and decline contact request'),
[__('Are you sure you want to decline the contact request from %1$s?', contact.getDisplayName())],
blocking_supported
? [
{
label: __('Also block this user from sending you further messages'),
name: 'block',
type: 'checkbox',
},
]
: []
);
if (result) {
const chat = await api.chats.get(contact.get('jid'));
chat?.close();
contact.unauthorize();
if (blocking_supported && Array.isArray(result) && result.find((i) => i.name === 'block')?.value === 'on') {
api.blocklist.add(contact.get('jid'));
api.toast.show('declined-and-blocked', {
type: 'success',
body: __('Contact request declined and user blocked'),
});
} else {
api.toast.show('request-declined', {
type: 'success',
body: __('Contact request declined'),
});
}
contact.destroy();
}
return this;
}
/**
* @param {RosterContact} contact
* @returns {Promise<boolean>}
*/
export async function blockContact(contact) {
const domain = _converse.session.get('domain');
if (!(await api.disco.supports(Strophe.NS.BLOCKING, domain))) return false;
const i18n_confirm = __('Do you want to block this contact, so they cannot send you messages?');
if (!(await api.confirm(__('Confirm'), i18n_confirm))) return false;
(await api.chats.get(contact.get('jid')))?.close();
try {
await Promise.all([api.blocklist.add(contact.get('jid')), contact.remove(true)]);
} catch (e) {
log.error(e);
api.alert('error', __('Error'), [
__('Sorry, an error occurred while trying to block %1$s', contact.getDisplayName()),
]);
}
}
/**
* @param {RosterContact} contact
* @returns {Promise<boolean>}
*/
export async function unblockContact(contact) {
const domain = _converse.session.get('domain');
if (!(await api.disco.supports(Strophe.NS.BLOCKING, domain))) return false;
const i18n_confirm = __('Do you want to unblock this contact, so they can send you messages?');
if (!(await api.confirm(i18n_confirm))) return false;
try {
await api.blocklist.remove(contact.get('jid'));
} catch (e) {
log.error(e);
api.alert('error', __('Error'), [
__('Sorry, an error occurred while trying to unblock %1$s', contact.getDisplayName()),
]);
}
}
/**
* @param {string} jid
*/
export function highlightRosterItem(jid) {
_converse.state.roster?.get(jid)?.trigger('highlight');
}
/**
* @param {Event} ev
* @param {string} name
*/
export function toggleGroup(ev, name) {
ev?.preventDefault?.();
const { roster } = _converse.state;
const collapsed = roster.state.get('collapsed_groups');
if (collapsed.includes(name)) {
roster.state.save(
'collapsed_groups',
collapsed.filter((n) => n !== name)
);
} else {
roster.state.save('collapsed_groups', [...collapsed, name]);
}
}
/**
* Return a string of tab-separated values that are to be used when
* matching against filter text.
*
* The goal is to be able to filter against the VCard fullname,
* roster nickname and JID.
* @param {RosterContact|Profile} contact
* @returns {string} Lower-cased, tab-separated values
*/
function getFilterCriteria(contact) {
const nick = contact instanceof Profile ? contact.getNickname() : contact.get('nickname');
const jid = contact.get('jid');
let criteria = contact.getDisplayName({ context: 'roster' });
criteria = !criteria.includes(jid) ? criteria.concat(` ${jid}`) : criteria;
criteria = !criteria.includes(nick) ? criteria.concat(` ${nick}`) : criteria;
return criteria.toLowerCase();
}
/**
* @param {RosterContact|Profile} contact
* @param {string} groupname
* @returns {boolean}
*/
export function isContactFiltered(contact, groupname) {
const filter = _converse.state.roster_filter;
const type = filter.get('type');
const q = type === 'state' ? filter.get('state').toLowerCase() : filter.get('text').toLowerCase();
if (!q) return false;
if (type === 'state') {
const sticky_groups = [_converse.labels.HEADER_REQUESTING_CONTACTS, _converse.labels.HEADER_UNREAD];
if (sticky_groups.includes(groupname)) {
// When filtering by chat state, we still want to
// show sticky groups, even though they don't
// match the state in question.
return false;
} else if (q === 'unread_messages') {
return contact.get('num_unread') === 0;
} else if (q === 'online') {
return ['offline', 'unavailable', 'dnd', 'away', 'xa'].includes(contact.getStatus());
} else {
return !contact.getStatus().includes(q);
}
} else if (type === 'items') {
return !getFilterCriteria(contact).includes(q);
}
}
/**
* @param {RosterContact} contact
* @param {string} groupname
* @param {Model} model
* @returns {boolean}
*/
export function shouldShowContact(contact, groupname, model) {
if (!model.get('filter_visible')) return true;
const chat_status = contact.getStatus();
if (api.settings.get('hide_offline_users') && chat_status === 'offline') {
// If pending or requesting, show
if (
contact.get('ask') === 'subscribe' ||
contact.get('subscription') === 'from' ||
contact.get('requesting') === true
) {
return !isContactFiltered(contact, groupname);
}
return false;
}
return !isContactFiltered(contact, groupname);
}
/**
* @param {string} group
* @param {Model} model
*/
export function shouldShowGroup(group, model) {
if (!model.get('filter_visible')) return true;
const filter = _converse.state.roster_filter;
const type = filter.get('type');
if (type === 'groups') {
const q = filter.get('text')?.toLowerCase();
if (!q) {
return true;
}
if (!group.toLowerCase().includes(q)) {
return false;
}
}
return true;
}
/**
* Populates a contacts map with the given contact, categorizing it into appropriate groups.
* @param {import('./types').ContactsMap} contacts_map
* @param {RosterContact} contact
* @returns {import('./types').ContactsMap}
*/
export function populateContactsMap(contacts_map, contact) {
const { labels } = _converse;
const contact_groups = /** @type {string[]} */ (u.unique(contact.get('groups') ?? []));
if (u.isOwnJID(contact.get('jid')) && !contact_groups.length) {
contact_groups.push(/** @type {string} */ (labels.HEADER_UNGROUPED));
} else if (contact.get('requesting')) {
contact_groups.push(/** @type {string} */ (labels.HEADER_REQUESTING_CONTACTS));
} else if (contact.get('ask') === 'subscribe') {
contact_groups.push(/** @type {string} */ (labels.HEADER_PENDING_CONTACTS));
} else if (contact.get('subscription') === undefined) {
contact_groups.push(/** @type {string} */ (labels.HEADER_UNSAVED_CONTACTS));
} else if (!api.settings.get('roster_groups')) {
contact_groups.push(/** @type {string} */ (labels.HEADER_CURRENT_CONTACTS));
} else if (!contact_groups.length) {
contact_groups.push(/** @type {string} */ (labels.HEADER_UNGROUPED));
}
for (const name of contact_groups) {
if (contacts_map[name]?.includes(contact)) {
continue;
}
contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
}
if (contact.get('num_unread')) {
const name = /** @type {string} */ (labels.HEADER_UNREAD);
contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
}
return contacts_map;
}
/**
* @param {RosterContact|Profile} contact1
* @param {RosterContact|Profile} contact2
* @returns {(-1|0|1)}
*/
export function contactsComparator(contact1, contact2) {
const status1 = contact1.getStatus();
const status2 = contact2.getStatus();
if (STATUS_WEIGHTS[status1] === STATUS_WEIGHTS[status2]) {
const name1 = contact1.getDisplayName().toLowerCase();
const name2 = contact2.getDisplayName().toLowerCase();
return name1 < name2 ? -1 : name1 > name2 ? 1 : 0;
} else {
return STATUS_WEIGHTS[status1] < STATUS_WEIGHTS[status2] ? -1 : 1;
}
}
/**
* @param {string} a
* @param {string} b
*/
export function groupsComparator(a, b) {
const HEADER_WEIGHTS = {};
const {
HEADER_CURRENT_CONTACTS,
HEADER_REQUESTING_CONTACTS,
HEADER_UNGROUPED,
HEADER_UNREAD,
HEADER_PENDING_CONTACTS,
HEADER_UNSAVED_CONTACTS,
} = _converse.labels;
HEADER_WEIGHTS[HEADER_UNREAD] = 0;
HEADER_WEIGHTS[HEADER_UNSAVED_CONTACTS] = 1;
HEADER_WEIGHTS[HEADER_REQUESTING_CONTACTS] = 2;
HEADER_WEIGHTS[HEADER_CURRENT_CONTACTS] = 3;
HEADER_WEIGHTS[HEADER_UNGROUPED] = 4;
HEADER_WEIGHTS[HEADER_PENDING_CONTACTS] = 5;
const WEIGHTS = HEADER_WEIGHTS;
const special_groups = Object.keys(HEADER_WEIGHTS);
const a_is_special = special_groups.includes(a);
const b_is_special = special_groups.includes(b);
if (!a_is_special && !b_is_special) {
return a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0;
} else if (a_is_special && b_is_special) {
return WEIGHTS[a] < WEIGHTS[b] ? -1 : WEIGHTS[a] > WEIGHTS[b] ? 1 : 0;
} else if (!a_is_special && b_is_special) {
const a_header = HEADER_CURRENT_CONTACTS;
return WEIGHTS[a_header] < WEIGHTS[b] ? -1 : WEIGHTS[a_header] > WEIGHTS[b] ? 1 : 0;
} else if (a_is_special && !b_is_special) {
const b_header = HEADER_CURRENT_CONTACTS;
return WEIGHTS[a] < WEIGHTS[b_header] ? -1 : WEIGHTS[a] > WEIGHTS[b_header] ? 1 : 0;
}
}
export function getGroupsAutoCompleteList() {
const roster = /** @type {RosterContacts} */ (_converse.state.roster);
const groups = roster.reduce((groups, contact) => groups.concat(contact.get('groups')), []);
return [...new Set(groups.filter((i) => i))];
}
export function getJIDsAutoCompleteList() {
const roster = /** @type {RosterContacts} */ (_converse.state.roster);
return [
...new Set([
...roster.map((item) => Strophe.getDomainFromJid(item.get('jid'))),
_converse.session.get('domain'),
]),
];
}
/**
* @param {string} query
*/
export async function getNamesAutoCompleteList(query) {
const options = {
mode: /** @type {RequestMode} */ ('cors'),
headers: {
Accept: 'text/json',
},
};
const url = `${api.settings.get('xhr_user_search_url')}q=${encodeURIComponent(query)}`;
let response;
try {
response = await fetch(url, options);
} catch (e) {
log.error(`Failed to fetch names for query "${query}"`);
log.error(e);
return [];
}
const json = await response.json();
if (!Array.isArray(json)) {
log.error(`Invalid JSON returned"`);
return [];
}
return json.map((i) => ({
label: `${i.fullname} <${i.jid}>`,
value: `${i.fullname} <${i.jid}>`,
...i, // Return rest of the JSON as well, could be useful to 3rd party plugin
}));
}
Object.assign(u, {
rosterview: {
removeContact,
declineContactRequest,
blockContact,
unblockContact,
highlightRosterItem,
toggleGroup,
getFilterCriteria,
isContactFiltered,
shouldShowContact,
shouldShowGroup,
populateContactsMap,
contactsComparator,
groupsComparator,
getGroupsAutoCompleteList,
getJIDsAutoCompleteList,
getNamesAutoCompleteList,
},
});