utils.js 11 KB

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