utils.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import ModeratorToolsModal from './modals/moderator-tools.js';
  2. import OccupantModal from './modals/occupant.js';
  3. import log from "@converse/headless/log";
  4. import tpl_spinner from 'templates/spinner.js';
  5. import { __ } from 'i18n';
  6. import { _converse, api, converse } from "@converse/headless/core";
  7. import { html } from "lit";
  8. import { setAffiliation } from '@converse/headless/plugins/muc/affiliations/utils.js';
  9. const { Strophe, $iq, sizzle, u } = converse.env;
  10. const COMMAND_TO_AFFILIATION = {
  11. 'admin': 'admin',
  12. 'ban': 'outcast',
  13. 'member': 'member',
  14. 'owner': 'owner',
  15. 'revoke': 'none'
  16. };
  17. const COMMAND_TO_ROLE = {
  18. 'deop': 'participant',
  19. 'kick': 'none',
  20. 'mute': 'visitor',
  21. 'op': 'moderator',
  22. 'voice': 'participant'
  23. };
  24. export function clearHistory (jid) {
  25. if (_converse.router.history.getFragment() === `converse/room?jid=${jid}`) {
  26. _converse.router.navigate('');
  27. }
  28. }
  29. export async function destroyMUC (model) {
  30. const messages = [__('Are you sure you want to destroy this groupchat?')];
  31. let fields = [
  32. {
  33. 'name': 'challenge',
  34. 'label': __('Please enter the XMPP address of this groupchat to confirm'),
  35. 'challenge': model.get('jid'),
  36. 'placeholder': __('name@example.org'),
  37. 'required': true
  38. },
  39. {
  40. 'name': 'reason',
  41. 'label': __('Optional reason for destroying this groupchat'),
  42. 'placeholder': __('Reason')
  43. },
  44. {
  45. 'name': 'newjid',
  46. 'label': __('Optional XMPP address for a new groupchat that replaces this one'),
  47. 'placeholder': __('replacement@example.org')
  48. }
  49. ];
  50. try {
  51. fields = await api.confirm(__('Confirm'), messages, fields);
  52. const reason = fields.filter(f => f.name === 'reason').pop()?.value;
  53. const newjid = fields.filter(f => f.name === 'newjid').pop()?.value;
  54. return model.sendDestroyIQ(reason, newjid).then(() => model.close());
  55. } catch (e) {
  56. log.error(e);
  57. }
  58. }
  59. function setMUCDomain (domain, controlboxview) {
  60. controlboxview.querySelector('converse-rooms-list')
  61. .model.save('muc_domain', Strophe.getDomainFromJid(domain));
  62. }
  63. function setMUCDomainFromDisco (controlboxview) {
  64. /* Check whether service discovery for the user's domain
  65. * returned MUC information and use that to automatically
  66. * set the MUC domain in the "Add groupchat" modal.
  67. */
  68. function featureAdded (feature) {
  69. if (!feature) {
  70. return;
  71. }
  72. if (feature.get('var') === Strophe.NS.MUC) {
  73. feature.entity.getIdentity('conference', 'text').then(identity => {
  74. if (identity) {
  75. setMUCDomain(feature.get('from'), controlboxview);
  76. }
  77. });
  78. }
  79. }
  80. api.waitUntil('discoInitialized')
  81. .then(() => {
  82. api.listen.on('serviceDiscovered', featureAdded);
  83. // Features could have been added before the controlbox was
  84. // initialized. We're only interested in MUC
  85. _converse.disco_entities.each(entity => featureAdded(entity.features.findWhere({ 'var': Strophe.NS.MUC })));
  86. })
  87. .catch(e => log.error(e));
  88. }
  89. export function fetchAndSetMUCDomain (controlboxview) {
  90. if (controlboxview.model.get('connected')) {
  91. if (!controlboxview.querySelector('converse-rooms-list').model.get('muc_domain')) {
  92. if (api.settings.get('muc_domain') === undefined) {
  93. setMUCDomainFromDisco(controlboxview);
  94. } else {
  95. setMUCDomain(api.settings.get('muc_domain'), controlboxview);
  96. }
  97. }
  98. }
  99. }
  100. export function getNicknameRequiredTemplate (model) {
  101. const jid = model.get('jid');
  102. if (api.settings.get('muc_show_logs_before_join')) {
  103. return html`<converse-muc-chatarea jid="${jid}"></converse-muc-chatarea>`;
  104. } else {
  105. return html`<converse-muc-nickname-form jid="${jid}"></converse-muc-nickname-form>`;
  106. }
  107. }
  108. export function getChatRoomBodyTemplate (o) {
  109. const view = o.model.session.get('view');
  110. const jid = o.model.get('jid');
  111. const RS = converse.ROOMSTATUS;
  112. const conn_status = o.model.session.get('connection_status');
  113. if (view === converse.MUC.VIEWS.CONFIG) {
  114. return html`<converse-muc-config-form class="muc-form-container" jid="${jid}"></converse-muc-config-form>`;
  115. } else {
  116. return html`
  117. ${ conn_status == RS.PASSWORD_REQUIRED ? html`<converse-muc-password-form class="muc-form-container" jid="${jid}"></converse-muc-password-form>` : '' }
  118. ${ conn_status == RS.ENTERED ? html`<converse-muc-chatarea jid="${jid}"></converse-muc-chatarea>` : '' }
  119. ${ conn_status == RS.CONNECTING ? tpl_spinner() : '' }
  120. ${ conn_status == RS.NICKNAME_REQUIRED ? getNicknameRequiredTemplate(o.model) : '' }
  121. ${ conn_status == RS.DISCONNECTED ? html`<converse-muc-disconnected jid="${jid}"></converse-muc-disconnected>` : '' }
  122. ${ conn_status == RS.BANNED ? html`<converse-muc-disconnected jid="${jid}"></converse-muc-disconnected>` : '' }
  123. ${ conn_status == RS.DESTROYED ? html`<converse-muc-destroyed jid="${jid}"></converse-muc-destroyed>` : '' }
  124. `;
  125. }
  126. }
  127. export function getAutoCompleteListItem (text, input) {
  128. input = input.trim();
  129. const element = document.createElement('li');
  130. element.setAttribute('aria-selected', 'false');
  131. if (api.settings.get('muc_mention_autocomplete_show_avatar')) {
  132. const img = document.createElement('img');
  133. let dataUri = 'data:' + _converse.DEFAULT_IMAGE_TYPE + ';base64,' + _converse.DEFAULT_IMAGE;
  134. if (_converse.vcards) {
  135. const vcard = _converse.vcards.findWhere({ 'nickname': text });
  136. if (vcard) dataUri = 'data:' + vcard.get('image_type') + ';base64,' + vcard.get('image');
  137. }
  138. img.setAttribute('src', dataUri);
  139. img.setAttribute('width', '22');
  140. img.setAttribute('class', 'avatar avatar-autocomplete');
  141. element.appendChild(img);
  142. }
  143. const regex = new RegExp('(' + input + ')', 'ig');
  144. const parts = input ? text.split(regex) : [text];
  145. parts.forEach(txt => {
  146. if (input && txt.match(regex)) {
  147. const match = document.createElement('mark');
  148. match.textContent = txt;
  149. element.appendChild(match);
  150. } else {
  151. element.appendChild(document.createTextNode(txt));
  152. }
  153. });
  154. return element;
  155. }
  156. export async function getAutoCompleteList () {
  157. const models = [...(await api.rooms.get()), ...(await api.contacts.get())];
  158. const jids = [...new Set(models.map(o => Strophe.getDomainFromJid(o.get('jid'))))];
  159. return jids;
  160. }
  161. export async function fetchCommandForm (command) {
  162. const node = command.node;
  163. const jid = command.jid;
  164. const stanza = $iq({
  165. 'type': 'set',
  166. 'to': jid
  167. }).c('command', {
  168. 'xmlns': Strophe.NS.ADHOC,
  169. 'node': node,
  170. 'action': 'execute'
  171. });
  172. try {
  173. const iq = await api.sendIQ(stanza);
  174. const cmd_el = sizzle(`command[xmlns="${Strophe.NS.ADHOC}"]`, iq).pop();
  175. command.sessionid = cmd_el.getAttribute('sessionid');
  176. command.instructions = sizzle('x[type="form"][xmlns="jabber:x:data"] instructions', cmd_el).pop()?.textContent;
  177. command.fields = sizzle('x[type="form"][xmlns="jabber:x:data"] field', cmd_el)
  178. .map(f => u.xForm2TemplateResult(f, cmd_el));
  179. } catch (e) {
  180. if (e === null) {
  181. log.error(`Error: timeout while trying to execute command for ${jid}`);
  182. } else {
  183. log.error(`Error while trying to execute command for ${jid}`);
  184. log.error(e);
  185. }
  186. command.fields = [];
  187. }
  188. }
  189. function setRole (muc, command, args, required_affiliations = [], required_roles = []) {
  190. const role = COMMAND_TO_ROLE[command];
  191. if (!role) {
  192. throw Error(`ChatRoomView#setRole called with invalid command: ${command}`);
  193. }
  194. if (!muc.verifyAffiliations(required_affiliations) || !muc.verifyRoles(required_roles)) {
  195. return false;
  196. }
  197. if (!muc.validateRoleOrAffiliationChangeArgs(command, args)) {
  198. return false;
  199. }
  200. const nick_or_jid = muc.getNickOrJIDFromCommandArgs(args);
  201. if (!nick_or_jid) {
  202. return false;
  203. }
  204. const reason = args.split(nick_or_jid, 2)[1].trim();
  205. // We're guaranteed to have an occupant due to getNickOrJIDFromCommandArgs
  206. const occupant = muc.getOccupant(nick_or_jid);
  207. muc.setRole(occupant, role, reason, undefined, e => muc.onCommandError(e));
  208. return true;
  209. }
  210. function verifyAndSetAffiliation (muc, command, args, required_affiliations) {
  211. const affiliation = COMMAND_TO_AFFILIATION[command];
  212. if (!affiliation) {
  213. throw Error(`verifyAffiliations called with invalid command: ${command}`);
  214. }
  215. if (!muc.verifyAffiliations(required_affiliations)) {
  216. return false;
  217. }
  218. if (!muc.validateRoleOrAffiliationChangeArgs(command, args)) {
  219. return false;
  220. }
  221. const nick_or_jid = muc.getNickOrJIDFromCommandArgs(args);
  222. if (!nick_or_jid) {
  223. return false;
  224. }
  225. let jid;
  226. const reason = args.split(nick_or_jid, 2)[1].trim();
  227. const occupant = muc.getOccupant(nick_or_jid);
  228. if (occupant) {
  229. jid = occupant.get('jid');
  230. } else {
  231. if (u.isValidJID(nick_or_jid)) {
  232. jid = nick_or_jid;
  233. } else {
  234. const message = __(
  235. "Couldn't find a participant with that nickname. " + 'They might have left the groupchat.'
  236. );
  237. muc.createMessage({ message, 'type': 'error' });
  238. return;
  239. }
  240. }
  241. const attrs = { jid, reason };
  242. if (occupant && api.settings.get('auto_register_muc_nickname')) {
  243. attrs['nick'] = occupant.get('nick');
  244. }
  245. setAffiliation(affiliation, muc.get('jid'), [attrs])
  246. .then(() => muc.occupants.fetchMembers())
  247. .catch(err => muc.onCommandError(err));
  248. }
  249. export function showModeratorToolsModal (muc, affiliation) {
  250. if (!muc.verifyRoles(['moderator'])) {
  251. return;
  252. }
  253. let modal = api.modal.get(ModeratorToolsModal.id);
  254. if (modal) {
  255. modal.affiliation = affiliation;
  256. modal.render();
  257. } else {
  258. modal = api.modal.create(ModeratorToolsModal, { affiliation, 'jid': muc.get('jid') });
  259. }
  260. modal.show();
  261. }
  262. export function showOccupantModal (ev, occupant) {
  263. api.modal.show(OccupantModal, { 'model': occupant }, ev);
  264. }
  265. export function parseMessageForMUCCommands (data, handled) {
  266. const model = data.model;
  267. if (handled ||
  268. model.get('type') !== _converse.CHATROOMS_TYPE || (
  269. api.settings.get('muc_disable_slash_commands') &&
  270. !Array.isArray(api.settings.get('muc_disable_slash_commands'))
  271. )) {
  272. return handled;
  273. }
  274. let text = data.text;
  275. text = text.replace(/^\s*/, '');
  276. const command = (text.match(/^\/([a-zA-Z]*) ?/) || ['']).pop().toLowerCase();
  277. if (!command) {
  278. return false;
  279. }
  280. const args = text.slice(('/' + command).length + 1).trim();
  281. const allowed_commands = model.getAllowedCommands() ?? [];
  282. if (command === 'admin' && allowed_commands.includes(command)) {
  283. verifyAndSetAffiliation(model, command, args, ['owner']);
  284. return true;
  285. } else if (command === 'ban' && allowed_commands.includes(command)) {
  286. verifyAndSetAffiliation(model, command, args, ['admin', 'owner']);
  287. return true;
  288. } else if (command === 'modtools' && allowed_commands.includes(command)) {
  289. showModeratorToolsModal(model, args);
  290. return true;
  291. } else if (command === 'deop' && allowed_commands.includes(command)) {
  292. // FIXME: /deop only applies to setting a moderators
  293. // role to "participant" (which only admin/owner can
  294. // do). Moderators can however set non-moderator's role
  295. // to participant (e.g. visitor => participant).
  296. // Currently we don't distinguish between these two
  297. // cases.
  298. setRole(model, command, args, ['admin', 'owner']);
  299. return true;
  300. } else if (command === 'destroy' && allowed_commands.includes(command)) {
  301. if (!model.verifyAffiliations(['owner'])) {
  302. return true;
  303. }
  304. destroyMUC(model).catch(e => model.onCommandError(e));
  305. return true;
  306. } else if (command === 'help' && allowed_commands.includes(command)) {
  307. model.set({ 'show_help_messages': false }, { 'silent': true });
  308. model.set({ 'show_help_messages': true });
  309. return true;
  310. } else if (command === 'kick' && allowed_commands.includes(command)) {
  311. setRole(model, command, args, [], ['moderator']);
  312. return true;
  313. } else if (command === 'mute' && allowed_commands.includes(command)) {
  314. setRole(model, command, args, [], ['moderator']);
  315. return true;
  316. } else if (command === 'member' && allowed_commands.includes(command)) {
  317. verifyAndSetAffiliation(model, command, args, ['admin', 'owner']);
  318. return true;
  319. } else if (command === 'nick' && allowed_commands.includes(command)) {
  320. if (!model.verifyRoles(['visitor', 'participant', 'moderator'])) {
  321. return true;
  322. } else if (args.length === 0) {
  323. // e.g. Your nickname is "coolguy69"
  324. const message = __('Your nickname is "%1$s"', model.get('nick'));
  325. model.createMessage({ message, 'type': 'error' });
  326. } else {
  327. model.setNickname(args);
  328. }
  329. return true;
  330. } else if (command === 'owner' && allowed_commands.includes(command)) {
  331. verifyAndSetAffiliation(model, command, args, ['owner']);
  332. return true;
  333. } else if (command === 'op' && allowed_commands.includes(command)) {
  334. setRole(model, command, args, ['admin', 'owner']);
  335. return true;
  336. } else if (command === 'register' && allowed_commands.includes(command)) {
  337. if (args.length > 1) {
  338. model.createMessage({
  339. 'message': __('Error: invalid number of arguments'),
  340. 'type': 'error'
  341. });
  342. } else {
  343. model.registerNickname().then(err_msg => {
  344. err_msg && model.createMessage({ 'message': err_msg, 'type': 'error' });
  345. });
  346. }
  347. return true;
  348. } else if (command === 'revoke' && allowed_commands.includes(command)) {
  349. verifyAndSetAffiliation(model, command, args, ['admin', 'owner']);
  350. return true;
  351. } else if (command === 'topic' && allowed_commands.includes(command) ||
  352. command === 'subject' && allowed_commands.includes(command)) {
  353. model.setSubject(args);
  354. return true;
  355. } else if (command === 'voice' && allowed_commands.includes(command)) {
  356. setRole(model, command, args, [], ['moderator']);
  357. return true;
  358. } else {
  359. return false;
  360. }
  361. }