diff --git a/spec/chatbox.js b/spec/chatbox.js index 3bb371e92..273c15778 100644 --- a/spec/chatbox.js +++ b/spec/chatbox.js @@ -911,7 +911,7 @@ describe("Chatboxes", function () { describe("Special Messages", function () { - it("'/clear' can be used to clear messages in a conversation", + fit("'/clear' can be used to clear messages in a conversation", mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) { await mock.waitForRoster(_converse, 'current'); @@ -925,28 +925,21 @@ describe("Chatboxes", function () { await mock.sendMessage(view, message); expect(view.model.messages.length === 1).toBeTruthy(); - let stored_messages = await view.model.messages.browserStorage.findAll(); + const stored_messages = await view.model.messages.browserStorage.findAll(); expect(stored_messages.length).toBe(1); await u.waitUntil(() => view.querySelector('.chat-msg')); message = '/clear'; const bottom_panel = view.querySelector('converse-chat-bottom-panel'); - spyOn(bottom_panel, 'clearMessages').and.callThrough(); - spyOn(window, 'confirm').and.callFake(function () { - return true; - }); + spyOn(window, 'confirm').and.callFake(() => true); view.querySelector('.chat-textarea').value = message; bottom_panel.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), preventDefault: function preventDefault () {}, keyCode: 13 }); - expect(bottom_panel.clearMessages.calls.all().length).toBe(1); - await bottom_panel.clearMessages.calls.all()[0].returnValue; - expect(window.confirm).toHaveBeenCalled(); - expect(view.model.messages.length, 0); // The messages must be removed from the chatbox - stored_messages = await view.model.messages.browserStorage.findAll(); - expect(stored_messages.length).toBe(0); + expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to clear the messages from this conversation?'); + await u.waitUntil(() => view.model.messages.length === 0); expect(_converse.api.trigger.calls.count(), 1); expect(_converse.api.trigger.calls.mostRecent().args, ['messageSend', message]); done(); diff --git a/spec/muc.js b/spec/muc.js index b82d5b0f5..e4758158e 100644 --- a/spec/muc.js +++ b/spec/muc.js @@ -3294,13 +3294,13 @@ describe("Groupchats", function () { const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = '/clear'; const bottom_panel = view.querySelector('converse-muc-bottom-panel'); - spyOn(bottom_panel, 'clearMessages'); + spyOn(window, 'confirm').and.callFake(() => false); bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 }); - expect(bottom_panel.clearMessages).toHaveBeenCalled(); + expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to clear the messages from this conversation?'); done(); })); diff --git a/src/plugins/chatview/bottom_panel.js b/src/plugins/chatview/bottom_panel.js index d7bf2b1a5..fb43915cf 100644 --- a/src/plugins/chatview/bottom_panel.js +++ b/src/plugins/chatview/bottom_panel.js @@ -4,6 +4,7 @@ import { ElementView } from '@converse/skeletor/src/element.js'; import { __ } from 'i18n'; import { _converse, api, converse } from "@converse/headless/core"; import { html, render } from 'lit-html'; +import { clearMessages, parseMessageForCommands } from './utils.js'; const { u } = converse.env; @@ -109,29 +110,13 @@ export default class ChatBottomPanel extends ElementView { ev.preventDefault(); } - async clearMessages (ev) { + clearMessages (ev) { ev?.preventDefault?.(); - const result = confirm(__('Are you sure you want to clear the messages from this conversation?')); - if (result === true) { - await this.model.clearMessages(); - } - return this; + clearMessages(this.model); } parseMessageForCommands (text) { - const match = text.replace(/^\s*/, '').match(/^\/(.*)\s*$/); - if (match) { - if (match[1] === 'clear') { - this.clearMessages(); - return true; - } else if (match[1] === 'close') { - _converse.chatboxviews.get(this.getAttribute('jid'))?.close(); - return true; - } else if (match[1] === 'help') { - this.model.set({ 'show_help_messages': true }); - return true; - } - } + return parseMessageForCommands(this.model, text); } async onFormSubmitted () { diff --git a/src/plugins/chatview/utils.js b/src/plugins/chatview/utils.js index 8104c2313..79a798931 100644 --- a/src/plugins/chatview/utils.js +++ b/src/plugins/chatview/utils.js @@ -1,3 +1,5 @@ +import { __ } from 'i18n'; +import { _converse } from "@converse/headless/core"; import { html } from 'lit-html'; @@ -21,3 +23,27 @@ export async function getHeadingStandaloneButton (promise_or_data) { > `; } + +async function clearMessages (chat) { + const result = confirm(__('Are you sure you want to clear the messages from this conversation?')); + if (result === true) { + await chat.clearMessages(); + } +} + + +export function parseMessageForCommands (chat, text) { + const match = text.replace(/^\s*/, '').match(/^\/(.*)\s*$/); + if (match) { + if (match[1] === 'clear') { + clearMessages(chat); + return true; + } else if (match[1] === 'close') { + _converse.chatboxviews.get(chat.get('jid'))?.close(); + return true; + } else if (match[1] === 'help') { + chat.set({ 'show_help_messages': true }); + return true; + } + } +} diff --git a/src/plugins/muc-views/bottom_panel.js b/src/plugins/muc-views/bottom_panel.js index 500d486c1..74cf5e134 100644 --- a/src/plugins/muc-views/bottom_panel.js +++ b/src/plugins/muc-views/bottom_panel.js @@ -3,29 +3,9 @@ import debounce from 'lodash/debounce'; import tpl_muc_bottom_panel from './templates/muc-bottom-panel.js'; import { __ } from 'i18n'; import { _converse, api, converse } from "@converse/headless/core"; -import { getAutoCompleteListItem } from './utils.js'; +import { getAutoCompleteListItem, parseMessageForMUCCommands } from './utils.js'; import { render } from 'lit-html'; -const { Strophe, $pres } = converse.env; - - -const COMMAND_TO_AFFILIATION = { - 'admin': 'admin', - 'ban': 'outcast', - 'member': 'member', - 'owner': 'owner', - 'revoke': 'none' -}; -const COMMAND_TO_ROLE = { - 'deop': 'participant', - 'kick': 'none', - 'mute': 'visitor', - 'op': 'moderator', - 'voice': 'participant' -}; - -const u = converse.env.utils; - export default class MUCBottomPanel extends BottomPanel { @@ -117,194 +97,8 @@ export default class MUCBottomPanel extends BottomPanel { super.onKeyUp(ev); } - setRole (command, args, required_affiliations = [], required_roles = []) { - /* Check that a command to change a groupchat user's role or - * affiliation has anough arguments. - */ - const role = COMMAND_TO_ROLE[command]; - if (!role) { - throw Error(`ChatRoomView#setRole called with invalid command: ${command}`); - } - if (!this.model.verifyAffiliations(required_affiliations) || !this.model.verifyRoles(required_roles)) { - return false; - } - if (!this.model.validateRoleOrAffiliationChangeArgs(command, args)) { - return false; - } - const nick_or_jid = this.model.getNickOrJIDFromCommandArgs(args); - if (!nick_or_jid) { - return false; - } - const reason = args.split(nick_or_jid, 2)[1].trim(); - // We're guaranteed to have an occupant due to getNickOrJIDFromCommandArgs - const occupant = this.model.getOccupant(nick_or_jid); - this.model.setRole(occupant, role, reason, undefined, this.model.onCommandError.bind(this)); - return true; - } - - setAffiliation (command, args, required_affiliations) { - const affiliation = COMMAND_TO_AFFILIATION[command]; - if (!affiliation) { - throw Error(`ChatRoomView#setAffiliation called with invalid command: ${command}`); - } - if (!this.model.verifyAffiliations(required_affiliations)) { - return false; - } - if (!this.model.validateRoleOrAffiliationChangeArgs(command, args)) { - return false; - } - const nick_or_jid = this.model.getNickOrJIDFromCommandArgs(args); - if (!nick_or_jid) { - return false; - } - - let jid; - const reason = args.split(nick_or_jid, 2)[1].trim(); - const occupant = this.model.getOccupant(nick_or_jid); - if (occupant) { - jid = occupant.get('jid'); - } else { - if (u.isValidJID(nick_or_jid)) { - jid = nick_or_jid; - } else { - const message = __( - "Couldn't find a participant with that nickname. " + 'They might have left the groupchat.' - ); - this.model.createMessage({ message, 'type': 'error' }); - return; - } - } - const attrs = { jid, reason }; - if (occupant && api.settings.get('auto_register_muc_nickname')) { - attrs['nick'] = occupant.get('nick'); - } - this.model - .setAffiliation(affiliation, [attrs]) - .then(() => this.model.occupants.fetchMembers()) - .catch(err => this.model.onCommandError(err)); - } - - parseMessageForCommands (text) { - if ( - api.settings.get('muc_disable_slash_commands') && - !Array.isArray(api.settings.get('muc_disable_slash_commands')) - ) { - return super.parseMessageForCommands(text); - } - text = text.replace(/^\s*/, ''); - const command = (text.match(/^\/([a-zA-Z]*) ?/) || ['']).pop().toLowerCase(); - if (!command) { - return false; - } - const args = text.slice(('/' + command).length + 1).trim(); - if (!this.model.getAllowedCommands().includes(command)) { - return false; - } - - switch (command) { - case 'admin': { - this.setAffiliation(command, args, ['owner']); - break; - } - case 'ban': { - this.setAffiliation(command, args, ['admin', 'owner']); - break; - } - case 'modtools': { - const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); - chatview.showModeratorToolsModal(args); - break; - } - case 'deop': { - // FIXME: /deop only applies to setting a moderators - // role to "participant" (which only admin/owner can - // do). Moderators can however set non-moderator's role - // to participant (e.g. visitor => participant). - // Currently we don't distinguish between these two - // cases. - this.setRole(command, args, ['admin', 'owner']); - break; - } - case 'destroy': { - if (!this.model.verifyAffiliations(['owner'])) { - break; - } - const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); - chatview.destroy().catch(e => this.model.onCommandError(e)); - break; - } - case 'help': { - this.model.set({ 'show_help_messages': true }); - break; - } - case 'kick': { - this.setRole(command, args, [], ['moderator']); - break; - } - case 'mute': { - this.setRole(command, args, [], ['moderator']); - break; - } - case 'member': { - this.setAffiliation(command, args, ['admin', 'owner']); - break; - } - case 'nick': { - if (!this.model.verifyRoles(['visitor', 'participant', 'moderator'])) { - break; - } else if (args.length === 0) { - // e.g. Your nickname is "coolguy69" - const message = __('Your nickname is "%1$s"', this.model.get('nick')); - this.model.createMessage({ message, 'type': 'error' }); - } else { - const jid = Strophe.getBareJidFromJid(this.model.get('jid')); - api.send( - $pres({ - from: _converse.connection.jid, - to: `${jid}/${args}`, - id: u.getUniqueId() - }).tree() - ); - } - break; - } - case 'owner': - this.setAffiliation(command, args, ['owner']); - break; - case 'op': { - this.setRole(command, args, ['admin', 'owner']); - break; - } - case 'register': { - if (args.length > 1) { - this.model.createMessage({ - 'message': __('Error: invalid number of arguments'), - 'type': 'error' - }); - } else { - this.model.registerNickname().then(err_msg => { - err_msg && this.model.createMessage({ 'message': err_msg, 'type': 'error' }); - }); - } - break; - } - case 'revoke': { - this.setAffiliation(command, args, ['admin', 'owner']); - break; - } - case 'topic': - case 'subject': - this.model.setSubject(args); - break; - case 'voice': { - this.setRole(command, args, [], ['moderator']); - break; - } - default: - return super.parseMessageForCommands(text); - } - return true; + return parseMessageForMUCCommands(this.model, text); } } diff --git a/src/plugins/muc-views/utils.js b/src/plugins/muc-views/utils.js index 63d167a09..c72100d3f 100644 --- a/src/plugins/muc-views/utils.js +++ b/src/plugins/muc-views/utils.js @@ -1,7 +1,24 @@ -import { _converse, api, converse } from "@converse/headless/core"; import log from "@converse/headless/log"; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core"; +import { parseMessageForCommands } from 'plugins/chatview/utils.js'; -const { Strophe, $iq, sizzle, u } = converse.env; +const { Strophe, $pres, $iq, sizzle, u } = converse.env; + +const COMMAND_TO_AFFILIATION = { + 'admin': 'admin', + 'ban': 'outcast', + 'member': 'member', + 'owner': 'owner', + 'revoke': 'none' +}; +const COMMAND_TO_ROLE = { + 'deop': 'participant', + 'kick': 'none', + 'mute': 'visitor', + 'op': 'moderator', + 'voice': 'participant' +}; export function getAutoCompleteListItem (text, input) { @@ -75,3 +92,192 @@ export async function fetchCommandForm (command) { command.fields = []; } } + + +function setRole (muc, command, args, required_affiliations = [], required_roles = []) { + const role = COMMAND_TO_ROLE[command]; + if (!role) { + throw Error(`ChatRoomView#setRole called with invalid command: ${command}`); + } + if (!muc.verifyAffiliations(required_affiliations) || !muc.verifyRoles(required_roles)) { + return false; + } + if (!muc.validateRoleOrAffiliationChangeArgs(command, args)) { + return false; + } + const nick_or_jid = muc.getNickOrJIDFromCommandArgs(args); + if (!nick_or_jid) { + return false; + } + const reason = args.split(nick_or_jid, 2)[1].trim(); + // We're guaranteed to have an occupant due to getNickOrJIDFromCommandArgs + const occupant = muc.getOccupant(nick_or_jid); + muc.setRole(occupant, role, reason, undefined, e => muc.onCommandError(e)); + return true; +} + + +function setAffiliation (muc, command, args, required_affiliations) { + const affiliation = COMMAND_TO_AFFILIATION[command]; + if (!affiliation) { + throw Error(`ChatRoomView#setAffiliation called with invalid command: ${command}`); + } + if (!muc.verifyAffiliations(required_affiliations)) { + return false; + } + if (!muc.validateRoleOrAffiliationChangeArgs(command, args)) { + return false; + } + const nick_or_jid = muc.getNickOrJIDFromCommandArgs(args); + if (!nick_or_jid) { + return false; + } + + let jid; + const reason = args.split(nick_or_jid, 2)[1].trim(); + const occupant = muc.getOccupant(nick_or_jid); + if (occupant) { + jid = occupant.get('jid'); + } else { + if (u.isValidJID(nick_or_jid)) { + jid = nick_or_jid; + } else { + const message = __( + "Couldn't find a participant with that nickname. " + 'They might have left the groupchat.' + ); + muc.createMessage({ message, 'type': 'error' }); + return; + } + } + const attrs = { jid, reason }; + if (occupant && api.settings.get('auto_register_muc_nickname')) { + attrs['nick'] = occupant.get('nick'); + } + muc + .setAffiliation(affiliation, [attrs]) + .then(() => muc.occupants.fetchMembers()) + .catch(err => muc.onCommandError(err)); +} + + +export function parseMessageForMUCCommands (muc, text) { + if ( + api.settings.get('muc_disable_slash_commands') && + !Array.isArray(api.settings.get('muc_disable_slash_commands')) + ) { + return parseMessageForCommands(muc, text); + } + text = text.replace(/^\s*/, ''); + const command = (text.match(/^\/([a-zA-Z]*) ?/) || ['']).pop().toLowerCase(); + if (!command) { + return false; + } + const args = text.slice(('/' + command).length + 1).trim(); + if (!muc.getAllowedCommands().includes(command)) { + return false; + } + + switch (command) { + case 'admin': { + setAffiliation(muc, command, args, ['owner']); + break; + } + case 'ban': { + setAffiliation(muc, command, args, ['admin', 'owner']); + break; + } + case 'modtools': { + const chatview = _converse.chatboxviews.get(muc.get('jid')); + chatview.showModeratorToolsModal(args); + break; + } + case 'deop': { + // FIXME: /deop only applies to setting a moderators + // role to "participant" (which only admin/owner can + // do). Moderators can however set non-moderator's role + // to participant (e.g. visitor => participant). + // Currently we don't distinguish between these two + // cases. + setRole(muc, command, args, ['admin', 'owner']); + break; + } + case 'destroy': { + if (!muc.verifyAffiliations(['owner'])) { + break; + } + const chatview = _converse.chatboxviews.get(muc.get('jid')); + chatview.destroy().catch(e => muc.onCommandError(e)); + break; + } + case 'help': { + muc.set({ 'show_help_messages': true }); + break; + } + case 'kick': { + setRole(muc, command, args, [], ['moderator']); + break; + } + case 'mute': { + setRole(muc, command, args, [], ['moderator']); + break; + } + case 'member': { + setAffiliation(muc, command, args, ['admin', 'owner']); + break; + } + case 'nick': { + if (!muc.verifyRoles(['visitor', 'participant', 'moderator'])) { + break; + } else if (args.length === 0) { + // e.g. Your nickname is "coolguy69" + const message = __('Your nickname is "%1$s"', muc.get('nick')); + muc.createMessage({ message, 'type': 'error' }); + } else { + const jid = Strophe.getBareJidFromJid(muc.get('jid')); + api.send( + $pres({ + from: _converse.connection.jid, + to: `${jid}/${args}`, + id: u.getUniqueId() + }).tree() + ); + } + break; + } + case 'owner': + setAffiliation(muc, command, args, ['owner']); + break; + case 'op': { + setRole(muc, command, args, ['admin', 'owner']); + break; + } + case 'register': { + if (args.length > 1) { + muc.createMessage({ + 'message': __('Error: invalid number of arguments'), + 'type': 'error' + }); + } else { + muc.registerNickname().then(err_msg => { + err_msg && muc.createMessage({ 'message': err_msg, 'type': 'error' }); + }); + } + break; + } + case 'revoke': { + setAffiliation(muc, command, args, ['admin', 'owner']); + break; + } + case 'topic': + case 'subject': + muc.setSubject(args); + break; + case 'voice': { + setRole(muc, command, args, [], ['moderator']); + break; + } + default: + return parseMessageForCommands(muc, text); + } + return true; +}