utils.js 13 KB

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