utils.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. /**
  2. * @typedef {import('@converse/skeletor').Model} Model
  3. * @typedef {import('@converse/headless').RosterContact} RosterContact
  4. * @typedef {import('@converse/headless').RosterContacts} RosterContacts
  5. */
  6. import { __ } from 'i18n';
  7. import { _converse, api, converse, log, constants, u, Profile } from '@converse/headless';
  8. const { Strophe } = converse.env;
  9. const { STATUS_WEIGHTS } = constants;
  10. /**
  11. * @param {RosterContact} contact
  12. * @param {boolean} [unauthorize]
  13. * @returns {Promise<boolean>}
  14. */
  15. export async function removeContact(contact, unauthorize = false) {
  16. if (!api.settings.get('allow_contact_removal')) return;
  17. const result = await api.confirm(__('Confirm'), __('Are you sure you want to remove this contact?'));
  18. if (!result) return false;
  19. const chat = await api.chats.get(contact.get('jid'));
  20. chat?.close();
  21. try {
  22. await contact.remove(unauthorize);
  23. } catch (e) {
  24. log.error(e);
  25. api.alert('error', __('Error'), [
  26. __('Sorry, an error occurred while trying to remove %1$s as a contact', contact.getDisplayName()),
  27. ]);
  28. }
  29. return true;
  30. }
  31. /**
  32. * @param {RosterContact} contact
  33. */
  34. export async function declineContactRequest(contact) {
  35. const domain = _converse.session.get('domain');
  36. const blocking_supported = await api.disco.supports(Strophe.NS.BLOCKING, domain);
  37. const result = await api.confirm(
  38. __('Remove and decline contact request'),
  39. [__('Are you sure you want to decline the contact request from %1$s?', contact.getDisplayName())],
  40. blocking_supported
  41. ? [
  42. {
  43. label: __('Also block this user from sending you further messages'),
  44. name: 'block',
  45. type: 'checkbox',
  46. },
  47. ]
  48. : []
  49. );
  50. if (result) {
  51. const chat = await api.chats.get(contact.get('jid'));
  52. chat?.close();
  53. contact.unauthorize();
  54. if (blocking_supported && Array.isArray(result) && result.find((i) => i.name === 'block')?.value === 'on') {
  55. api.blocklist.add(contact.get('jid'));
  56. api.toast.show('', { body: __('Contact request declined and user blocked') });
  57. } else {
  58. api.toast.show('', { body: __('Contact request declined') });
  59. }
  60. contact.destroy();
  61. }
  62. return this;
  63. }
  64. /**
  65. * @param {RosterContact} contact
  66. * @returns {Promise<boolean>}
  67. */
  68. export async function blockContact(contact) {
  69. const domain = _converse.session.get('domain');
  70. if (!(await api.disco.supports(Strophe.NS.BLOCKING, domain))) return false;
  71. const i18n_confirm = __('Do you want to block this contact, so they cannot send you messages?');
  72. if (!(await api.confirm(i18n_confirm))) return false;
  73. (await api.chats.get(contact.get('jid')))?.close();
  74. try {
  75. await Promise.all([api.blocklist.add(contact.get('jid')), contact.remove(true)]);
  76. } catch (e) {
  77. log.error(e);
  78. api.alert('error', __('Error'), [
  79. __('Sorry, an error occurred while trying to block %1$s', contact.getDisplayName()),
  80. ]);
  81. }
  82. }
  83. /**
  84. * @param {RosterContact} contact
  85. * @returns {Promise<boolean>}
  86. */
  87. export async function unblockContact(contact) {
  88. const domain = _converse.session.get('domain');
  89. if (!(await api.disco.supports(Strophe.NS.BLOCKING, domain))) return false;
  90. const i18n_confirm = __('Do you want to unblock this contact, so they can send you messages?');
  91. if (!(await api.confirm(i18n_confirm))) return false;
  92. try {
  93. await api.blocklist.remove(contact.get('jid'));
  94. } catch (e) {
  95. log.error(e);
  96. api.alert('error', __('Error'), [
  97. __('Sorry, an error occurred while trying to unblock %1$s', contact.getDisplayName()),
  98. ]);
  99. }
  100. }
  101. /**
  102. * @param {string} jid
  103. */
  104. export function highlightRosterItem(jid) {
  105. _converse.state.roster?.get(jid)?.trigger('highlight');
  106. }
  107. /**
  108. * @param {Event} ev
  109. * @param {string} name
  110. */
  111. export function toggleGroup(ev, name) {
  112. ev?.preventDefault?.();
  113. const { roster } = _converse.state;
  114. const collapsed = roster.state.get('collapsed_groups');
  115. if (collapsed.includes(name)) {
  116. roster.state.save(
  117. 'collapsed_groups',
  118. collapsed.filter((n) => n !== name)
  119. );
  120. } else {
  121. roster.state.save('collapsed_groups', [...collapsed, name]);
  122. }
  123. }
  124. /**
  125. * Return a string of tab-separated values that are to be used when
  126. * matching against filter text.
  127. *
  128. * The goal is to be able to filter against the VCard fullname,
  129. * roster nickname and JID.
  130. * @param {RosterContact|Profile} contact
  131. * @returns {string} Lower-cased, tab-separated values
  132. */
  133. function getFilterCriteria(contact) {
  134. const nick = contact instanceof Profile ? contact.getNickname() : contact.get('nickname');
  135. const jid = contact.get('jid');
  136. let criteria = contact.getDisplayName({ context: 'roster' });
  137. criteria = !criteria.includes(jid) ? criteria.concat(` ${jid}`) : criteria;
  138. criteria = !criteria.includes(nick) ? criteria.concat(` ${nick}`) : criteria;
  139. return criteria.toLowerCase();
  140. }
  141. /**
  142. * @param {RosterContact|Profile} contact
  143. * @param {string} groupname
  144. * @returns {boolean}
  145. */
  146. export function isContactFiltered(contact, groupname) {
  147. const filter = _converse.state.roster_filter;
  148. const type = filter.get('type');
  149. const q = type === 'state' ? filter.get('state').toLowerCase() : filter.get('text').toLowerCase();
  150. if (!q) return false;
  151. if (type === 'state') {
  152. const sticky_groups = [_converse.labels.HEADER_REQUESTING_CONTACTS, _converse.labels.HEADER_UNREAD];
  153. if (sticky_groups.includes(groupname)) {
  154. // When filtering by chat state, we still want to
  155. // show sticky groups, even though they don't
  156. // match the state in question.
  157. return false;
  158. } else if (q === 'unread_messages') {
  159. return contact.get('num_unread') === 0;
  160. } else if (q === 'online') {
  161. return ['offline', 'unavailable', 'dnd', 'away', 'xa'].includes(contact.getStatus());
  162. } else {
  163. return !contact.getStatus().includes(q);
  164. }
  165. } else if (type === 'items') {
  166. return !getFilterCriteria(contact).includes(q);
  167. }
  168. }
  169. /**
  170. * @param {RosterContact} contact
  171. * @param {string} groupname
  172. * @param {Model} model
  173. * @returns {boolean}
  174. */
  175. export function shouldShowContact(contact, groupname, model) {
  176. if (!model.get('filter_visible')) return true;
  177. const chat_status = contact.getStatus();
  178. if (api.settings.get('hide_offline_users') && chat_status === 'offline') {
  179. // If pending or requesting, show
  180. if (
  181. contact.get('ask') === 'subscribe' ||
  182. contact.get('subscription') === 'from' ||
  183. contact.get('requesting') === true
  184. ) {
  185. return !isContactFiltered(contact, groupname);
  186. }
  187. return false;
  188. }
  189. return !isContactFiltered(contact, groupname);
  190. }
  191. /**
  192. * @param {string} group
  193. * @param {Model} model
  194. */
  195. export function shouldShowGroup(group, model) {
  196. if (!model.get('filter_visible')) return true;
  197. const filter = _converse.state.roster_filter;
  198. const type = filter.get('type');
  199. if (type === 'groups') {
  200. const q = filter.get('text')?.toLowerCase();
  201. if (!q) {
  202. return true;
  203. }
  204. if (!group.toLowerCase().includes(q)) {
  205. return false;
  206. }
  207. }
  208. return true;
  209. }
  210. /**
  211. * Populates a contacts map with the given contact, categorizing it into appropriate groups.
  212. * @param {import('./types').ContactsMap} contacts_map
  213. * @param {RosterContact} contact
  214. * @returns {import('./types').ContactsMap}
  215. */
  216. export function populateContactsMap(contacts_map, contact) {
  217. const { labels } = _converse;
  218. const contact_groups = /** @type {string[]} */ (u.unique(contact.get('groups') ?? []));
  219. if (u.isOwnJID(contact.get('jid')) && !contact_groups.length) {
  220. contact_groups.push(/** @type {string} */ (labels.HEADER_UNGROUPED));
  221. } else if (contact.get('requesting')) {
  222. contact_groups.push(/** @type {string} */ (labels.HEADER_REQUESTING_CONTACTS));
  223. } else if (contact.get('subscription') === undefined) {
  224. contact_groups.push(/** @type {string} */ (labels.HEADER_UNSAVED_CONTACTS));
  225. } else if (!api.settings.get('roster_groups')) {
  226. contact_groups.push(/** @type {string} */ (labels.HEADER_CURRENT_CONTACTS));
  227. } else if (!contact_groups.length) {
  228. contact_groups.push(/** @type {string} */ (labels.HEADER_UNGROUPED));
  229. }
  230. for (const name of contact_groups) {
  231. if (contacts_map[name]?.includes(contact)) {
  232. continue;
  233. }
  234. contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
  235. }
  236. if (contact.get('num_unread')) {
  237. const name = /** @type {string} */ (labels.HEADER_UNREAD);
  238. contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
  239. }
  240. return contacts_map;
  241. }
  242. /**
  243. * @param {RosterContact|Profile} contact1
  244. * @param {RosterContact|Profile} contact2
  245. * @returns {(-1|0|1)}
  246. */
  247. export function contactsComparator(contact1, contact2) {
  248. const status1 = contact1.getStatus();
  249. const status2 = contact2.getStatus();
  250. if (STATUS_WEIGHTS[status1] === STATUS_WEIGHTS[status2]) {
  251. const name1 = contact1.getDisplayName().toLowerCase();
  252. const name2 = contact2.getDisplayName().toLowerCase();
  253. return name1 < name2 ? -1 : name1 > name2 ? 1 : 0;
  254. } else {
  255. return STATUS_WEIGHTS[status1] < STATUS_WEIGHTS[status2] ? -1 : 1;
  256. }
  257. }
  258. /**
  259. * @param {string} a
  260. * @param {string} b
  261. */
  262. export function groupsComparator(a, b) {
  263. const HEADER_WEIGHTS = {};
  264. const {
  265. HEADER_CURRENT_CONTACTS,
  266. HEADER_REQUESTING_CONTACTS,
  267. HEADER_UNGROUPED,
  268. HEADER_UNREAD,
  269. HEADER_UNSAVED_CONTACTS,
  270. } = _converse.labels;
  271. HEADER_WEIGHTS[HEADER_UNREAD] = 0;
  272. HEADER_WEIGHTS[HEADER_UNSAVED_CONTACTS] = 1;
  273. HEADER_WEIGHTS[HEADER_REQUESTING_CONTACTS] = 2;
  274. HEADER_WEIGHTS[HEADER_CURRENT_CONTACTS] = 3;
  275. HEADER_WEIGHTS[HEADER_UNGROUPED] = 4;
  276. const WEIGHTS = HEADER_WEIGHTS;
  277. const special_groups = Object.keys(HEADER_WEIGHTS);
  278. const a_is_special = special_groups.includes(a);
  279. const b_is_special = special_groups.includes(b);
  280. if (!a_is_special && !b_is_special) {
  281. return a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0;
  282. } else if (a_is_special && b_is_special) {
  283. return WEIGHTS[a] < WEIGHTS[b] ? -1 : WEIGHTS[a] > WEIGHTS[b] ? 1 : 0;
  284. } else if (!a_is_special && b_is_special) {
  285. const a_header = HEADER_CURRENT_CONTACTS;
  286. return WEIGHTS[a_header] < WEIGHTS[b] ? -1 : WEIGHTS[a_header] > WEIGHTS[b] ? 1 : 0;
  287. } else if (a_is_special && !b_is_special) {
  288. const b_header = HEADER_CURRENT_CONTACTS;
  289. return WEIGHTS[a] < WEIGHTS[b_header] ? -1 : WEIGHTS[a] > WEIGHTS[b_header] ? 1 : 0;
  290. }
  291. }
  292. export function getGroupsAutoCompleteList() {
  293. const roster = /** @type {RosterContacts} */ (_converse.state.roster);
  294. const groups = roster.reduce((groups, contact) => groups.concat(contact.get('groups')), []);
  295. return [...new Set(groups.filter((i) => i))];
  296. }
  297. export function getJIDsAutoCompleteList() {
  298. const roster = /** @type {RosterContacts} */ (_converse.state.roster);
  299. return [...new Set(roster.map((item) => Strophe.getDomainFromJid(item.get('jid'))))];
  300. }
  301. /**
  302. * @param {string} query
  303. */
  304. export async function getNamesAutoCompleteList(query) {
  305. const options = {
  306. mode: /** @type {RequestMode} */ ('cors'),
  307. headers: {
  308. Accept: 'text/json',
  309. },
  310. };
  311. const url = `${api.settings.get('xhr_user_search_url')}q=${encodeURIComponent(query)}`;
  312. let response;
  313. try {
  314. response = await fetch(url, options);
  315. } catch (e) {
  316. log.error(`Failed to fetch names for query "${query}"`);
  317. log.error(e);
  318. return [];
  319. }
  320. const json = await response.json();
  321. if (!Array.isArray(json)) {
  322. log.error(`Invalid JSON returned"`);
  323. return [];
  324. }
  325. return json.map((i) => ({
  326. label: `${i.fullname} <${i.jid}>`,
  327. value: `${i.fullname} <${i.jid}>`,
  328. }));
  329. }