Add a new command /modtools

in which you can set user affiliations and roles.

Also, let getAffiliationList return an Error instead of `null` if you're
not allowed to fetch a particular affiliation list.
This commit is contained in:
JC Brand 2019-07-04 14:12:12 +02:00
parent a03e722a24
commit aee6a192d1
14 changed files with 653 additions and 86 deletions

View File

@ -1,6 +1,11 @@
# Changelog
## 5.0.1 (Unreleased)
- Add a new GUI for moderator actions. You can trigger it by entering `/modtools` in a MUC.
## 5.0.0 (2019-08-08)
- BOSH support has been moved to a plugin.
- Support for XEP-0410 to check whether we're still present in a room
- Initial support for the [CredentialsContainer](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer) web API

View File

@ -259,7 +259,6 @@ body.converse-fullscreen {
input[type=text], input[type=password],
button {
font-size: var(--font-size);
padding: 0.25em;
min-height: 0;
}

View File

@ -44,7 +44,7 @@
font-size: var(--font-size);
}
&#converse-register,
&#converse-register,
&#converse-login {
legend {
width: 100%;
@ -95,7 +95,6 @@
input[type=submit] {
padding-left: 1em;
padding-right: 1em;
margin: 0.5em 0;
border: none;
}
input.error {

View File

@ -1,5 +1,19 @@
#conversejs {
#converse-modals {
.modal-body {
margin-bottom: 2em;
}
.scrollable-container {
max-height: 50vh;
overflow-y: auto;
}
.role-form, .affiliation-form {
padding: 2em 0 1em 0;
}
.set-xmpp-status {
margin: 1em;
.custom-control-label {
@ -43,7 +57,7 @@
width: 100%;
margin-bottom: 1em;
}
.fingerprint-trust {
display: flex;
justify-content: space-between;

139
spec/modtools.js Normal file
View File

@ -0,0 +1,139 @@
(function (root, factory) {
define(["jasmine", "mock", "test-utils" ], factory);
} (this, function (jasmine, mock, test_utils) {
const _ = converse.env._;
const $iq = converse.env.$iq;
const sizzle = converse.env.sizzle;
const Strophe = converse.env.Strophe;
const u = converse.env.utils;
describe("The groupchat moderator tool", function () {
it("allows you to set affiliations and roles",
mock.initConverse(
null, ['rosterGroupsFetched'], {},
async function (done, _converse) {
spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
const muc_jid = 'lounge@montague.lit';
let members = [
{'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
{'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
{'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'},
{'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'owner'},
{'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'},
];
await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
const view = _converse.chatboxviews.get(muc_jid);
await u.waitUntil(() => (view.model.occupants.length === 5));
const textarea = view.el.querySelector('.chat-textarea');
textarea.value = '/modtools';
const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
view.onKeyDown(enter);
await u.waitUntil(() => view.showModeratorToolsModal.calls.count());
const modal = view.modtools_modal;
await u.waitUntil(() => u.isVisible(modal.el), 1000);
let tab = modal.el.querySelector('#affiliations-tab');
// Clear so that we don't match older stanzas
_converse.connection.IQ_stanzas = [];
tab.click();
let select = modal.el.querySelector('.select-affiliation');
expect(select.value).toBe('admin');
let button = modal.el.querySelector('.btn-primary[name="users_with_affiliation"]');
button.click();
await u.waitUntil(() => !modal.loading_users_with_affiliation);
let user_els = modal.el.querySelectorAll('.list-group--users > li');
expect(user_els.length).toBe(1);
expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: wiccarocks@shakespeare.lit');
expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: wiccan');
expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: admin');
_converse.connection.IQ_stanzas = [];
select.value = 'owner';
button.click();
await u.waitUntil(() => !modal.loading_users_with_affiliation);
user_els = modal.el.querySelectorAll('.list-group--users > li');
expect(user_els.length).toBe(2);
expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: romeo@montague.lit');
expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: romeo');
expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner');
expect(user_els[1].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: crone1@shakespeare.lit');
expect(user_els[1].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: thirdwitch');
expect(user_els[1].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner');
const toggle = user_els[1].querySelector('.list-group-item:nth-child(3n) .toggle-form');
const form = user_els[1].querySelector('.list-group-item:nth-child(3n) .affiliation-form');
expect(u.hasClass('hidden', form)).toBeTruthy();
toggle.click();
expect(u.hasClass('hidden', form)).toBeFalsy();
select = form.querySelector('.select-affiliation');
expect(select.value).toBe('owner');
select.value = 'admin';
const input = form.querySelector('input[name="reason"]');
input.value = "You're an admin now";
const submit = form.querySelector('.btn-primary');
submit.click();
spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
const sent_IQ = _converse.connection.IQ_stanzas.pop();
expect(Strophe.serialize(sent_IQ)).toBe(
`<iq id="${sent_IQ.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
`<query xmlns="http://jabber.org/protocol/muc#admin">`+
`<item affiliation="admin" jid="crone1@shakespeare.lit">`+
`<reason>You&apos;re an admin now</reason>`+
`</item>`+
`</query>`+
`</iq>`);
_converse.connection.IQ_stanzas = [];
const stanza = $iq({
'type': 'result',
'id': sent_IQ.getAttribute('id'),
'from': view.model.get('jid'),
'to': _converse.connection.jid
});
_converse.connection._dataRecv(test_utils.createRequest(stanza));
await u.waitUntil(() => view.model.occupants.fetchMembers.calls.count());
members = [
{'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
{'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
{'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'},
{'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'admin'},
{'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'},
];
await test_utils.returnMemberLists(_converse, muc_jid, members);
await u.waitUntil(() => view.model.occupants.pluck('affiliation').filter(o => o === 'owner').length === 1);
const alert = modal.el.querySelector('.alert-primary');
expect(alert.textContent.trim()).toBe('Affiliation changed');
user_els = modal.el.querySelectorAll('.list-group--users > li');
expect(user_els.length).toBe(1);
expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: romeo@montague.lit');
expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: romeo');
expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner');
tab = modal.el.querySelector('#roles-tab');
tab.click();
select = modal.el.querySelector('.select-role');
expect(u.isVisible(select)).toBe(true);
expect(select.value).toBe('moderator');
button = modal.el.querySelector('.btn-primary[name="users_with_role"]');
button.click();
const roles_panel = modal.el.querySelector('#roles-tabpanel');
await u.waitUntil(() => roles_panel.querySelectorAll('.list-group--users > li').length === 1);
select.value = 'participant';
button.click();
await u.waitUntil(() => !modal.loading_users_with_affiliation);
user_els = roles_panel.querySelectorAll('.list-group--users > li')
expect(user_els.length).toBe(1);
expect(user_els[0].textContent.trim()).toBe('No users with that role found.');
done();
}));
});
}));

View File

@ -1613,7 +1613,13 @@
async function (done, _converse) {
const muc_jid = 'lounge@montague.lit'
await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', [], ['juliet']);
const members = [{
'nick': 'juliet',
'jid': 'juliet@capulet.lit',
'affiliation': 'member'
}];
await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
const view = _converse.chatboxviews.get(muc_jid);
await u.waitUntil(() => view.model.occupants.length === 2);
@ -2975,7 +2981,7 @@
view.onKeyDown(enter);
let info_messages = Array.prototype.slice.call(view.el.querySelectorAll('.chat-info'), 0);
expect(info_messages.length).toBe(19);
expect(info_messages.length).toBe(20);
expect(info_messages.pop().textContent).toBe('/voice: Allow muted user to post messages');
expect(info_messages.pop().textContent).toBe('/topic: Set groupchat subject (alias for /subject)');
expect(info_messages.pop().textContent).toBe('/subject: Set groupchat subject');
@ -2985,6 +2991,7 @@
expect(info_messages.pop().textContent).toBe('/op: Grant moderator role to user');
expect(info_messages.pop().textContent).toBe('/nick: Change your nickname');
expect(info_messages.pop().textContent).toBe('/mute: Remove user\'s ability to post messages');
expect(info_messages.pop().textContent).toBe('/modtools: Opens up the moderator tools GUI');
expect(info_messages.pop().textContent).toBe('/member: Grant membership to a user');
expect(info_messages.pop().textContent).toBe('/me: Write in 3rd person');
expect(info_messages.pop().textContent).toBe('/kick: Kick user from groupchat');
@ -3003,11 +3010,11 @@
textarea.value = '/help';
view.onKeyDown(enter);
info_messages = sizzle('.chat-info', view.el).slice(1);
expect(info_messages.length).toBe(17);
expect(info_messages.length).toBe(18);
let commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
expect(commands).toEqual([
"/admin", "/ban", "/clear", "/deop", "/destroy",
"/help", "/kick", "/me", "/member", "/mute", "/nick",
"/help", "/kick", "/me", "/member", "/modtools", "/mute", "/nick",
"/op", "/register", "/revoke", "/subject", "/topic", "/voice"
]);
occupant.set('affiliation', 'member');
@ -3048,7 +3055,7 @@
view.onKeyDown(enter);
const info_messages = Array.prototype.slice.call(view.el.querySelectorAll('.chat-info'), 0);
expect(info_messages.length).toBe(17);
expect(info_messages.length).toBe(18);
expect(info_messages.pop().textContent).toBe('/topic: Set groupchat subject (alias for /subject)');
expect(info_messages.pop().textContent).toBe('/subject: Set groupchat subject');
expect(info_messages.pop().textContent).toBe('/revoke: Revoke the user\'s current affiliation');
@ -3056,6 +3063,7 @@
expect(info_messages.pop().textContent).toBe('/owner: Grant ownership of this groupchat');
expect(info_messages.pop().textContent).toBe('/op: Grant moderator role to user');
expect(info_messages.pop().textContent).toBe('/nick: Change your nickname');
expect(info_messages.pop().textContent).toBe('/modtools: Opens up the moderator tools GUI');
expect(info_messages.pop().textContent).toBe('/member: Grant membership to a user');
expect(info_messages.pop().textContent).toBe('/me: Write in 3rd person');
expect(info_messages.pop().textContent).toBe('/kick: Kick user from groupchat');
@ -5366,5 +5374,3 @@
});
});
}));

View File

@ -16,7 +16,6 @@ import BrowserStorage from "backbone.browserStorage";
import { Overview } from "backbone.overview";
import bootstrap from "bootstrap.native";
import converse from "@converse/headless/converse-core";
import tpl_alert from "templates/alert.html";
import tpl_chatbox from "templates/chatbox.html";
import tpl_chatbox_head from "templates/chatbox_head.html";
import tpl_chatbox_message_form from "templates/chatbox_message_form.html";
@ -275,13 +274,7 @@ converse.plugins.add('converse-chatview', {
await _converse.api.vcard.update(this.model.contact.vcard, true);
} catch (e) {
_converse.log(e, Strophe.LogLevel.FATAL);
this.el.querySelector('.modal-body').insertAdjacentHTML(
'afterBegin',
tpl_alert({
'type': 'alert-danger',
'message': __('Sorry, something went wrong while trying to refresh')
})
);
this.alert(__('Sorry, something went wrong while trying to refresh'), 'danger');
}
u.removeClass('fa-spin', refresh_icon);
},

View File

@ -9,9 +9,10 @@
import "backbone.vdomview";
import bootstrap from "bootstrap.native";
import converse from "@converse/headless/converse-core";
import tpl_alert from "templates/alert.html";
import tpl_alert_modal from "templates/alert_modal.html";
const { Strophe, Backbone, _ } = converse.env;
const { Strophe, Backbone, sizzle, _ } = converse.env;
const u = converse.env.utils;
@ -22,6 +23,10 @@ converse.plugins.add('converse-modal', {
_converse.BootstrapModal = Backbone.VDOMView.extend({
events: {
'click .nav-item .nav-link': 'switchTab'
},
initialize () {
this.render().insertIntoDOM();
this.modal = new bootstrap.Modal(this.el, {
@ -36,6 +41,33 @@ converse.plugins.add('converse-modal', {
container_el.insertAdjacentElement('beforeEnd', this.el);
},
switchTab (ev) {
ev.stopPropagation();
ev.preventDefault();
sizzle('.nav-link.active', this.el).forEach(el => {
u.removeClass('active', this.el.querySelector(el.getAttribute('href')));
u.removeClass('active', el);
});
u.addClass('active', ev.target);
u.addClass('active', this.el.querySelector(ev.target.getAttribute('href')))
},
alert (message, type='primary') {
const body = this.el.querySelector('.modal-body');
body.insertAdjacentHTML(
'afterBegin',
tpl_alert({
'type': `alert-${type}`,
'message': message
})
);
const el = body.firstElementChild;
setTimeout(() => {
u.addClass('fade-out', el);
setTimeout(() => u.removeElement(el), 600);
}, 5000);
},
show (ev) {
if (ev) {
ev.preventDefault();

View File

@ -14,6 +14,7 @@ import "backbone.vdomview";
import BrowserStorage from "backbone.browserStorage";
import { OrderedListView } from "backbone.overview";
import _FormData from "formdata-polyfill";
import bootstrap from "bootstrap.native";
import converse from "@converse/headless/converse-core";
import muc_utils from "@converse/headless/utils/muc";
import tpl_add_chatroom_modal from "templates/add_chatroom_modal.html";
@ -32,6 +33,7 @@ import tpl_chatroom_password_form from "templates/chatroom_password_form.html";
import tpl_chatroom_sidebar from "templates/chatroom_sidebar.html";
import tpl_info from "templates/info.html";
import tpl_list_chatrooms_modal from "templates/list_chatrooms_modal.html";
import tpl_moderator_tools_modal from "templates/moderator_tools_modal.html";
import tpl_occupant from "templates/occupant.html";
import tpl_room_description from "templates/room_description.html";
import tpl_room_item from "templates/room_item.html";
@ -43,8 +45,12 @@ import xss from "xss/dist/xss";
const { Backbone, Promise, Strophe, dayjs, sizzle, _, $iq, $msg, $pres } = converse.env;
const u = converse.env.utils;
const ROLES = ['moderator', 'participant', 'visitor'];
const AFFILIATIONS = ['admin', 'member', 'outcast', 'owner'];
const AFFILIATION_CHANGE_COMANDS = ['admin', 'ban', 'owner', 'member', 'revoke'];
const OWNER_COMMANDS = ['owner'];
const ADMIN_COMMANDS = ['admin', 'ban', 'deop', 'destroy', 'member', 'op', 'revoke'];
const ADMIN_COMMANDS = ['admin', 'ban', 'deop', 'destroy', 'modtools', 'member', 'op', 'revoke'];
const MODERATOR_COMMANDS = ['kick', 'mute', 'voice'];
const VISITOR_COMMANDS = ['nick'];
@ -202,6 +208,167 @@ converse.plugins.add('converse-muc-views', {
}
_converse.ModeratorToolsModal = _converse.BootstrapModal.extend({
events: {
'submit .affiliation-form': 'assignAffiliation',
'submit .role-form': 'assignRole',
'submit .query-affiliation': 'queryAffiliation',
'submit .query-role': 'queryRole',
'click .nav-item .nav-link': 'switchTab',
'click .toggle-form': 'toggleForm',
},
initialize (attrs) {
this.chatroomview = attrs.chatroomview;
_converse.BootstrapModal.prototype.initialize.apply(this, arguments);
this.model.on('change:role', () => {
this.users_with_role = this.getUsersWithRole();
this.render();
});
this.model.on('change:affiliation', async () => {
this.loading_users_with_affiliation = true;
this.users_with_affiliation = null;
this.render();
const affiliation = this.model.get('affiliation');
if (!_converse.muc_fetch_members || affiliation === 'outcast') {
this.users_with_affiliation = await this.chatroomview.model.getAffiliationList(affiliation);
} else {
this.users_with_affiliation = this.getUsersWithAffiliation();
}
this.loading_users_with_affiliation = false;
this.render();
});
},
toHTML () {
const allowed_commands = this.chatroomview.getAllowedCommands();
const allowed_affiliations = allowed_commands.map(c => COMMAND_TO_AFFILIATION[c]).filter(c => c);
const allowed_roles = _.uniq(allowed_commands
.map(c => COMMAND_TO_ROLE[c])
.filter(c => c));
allowed_affiliations.sort();
allowed_roles.sort();
return tpl_moderator_tools_modal(Object.assign(this.model.toJSON(), {
'__': __,
'affiliations': AFFILIATIONS,
'allowed_affiliations': allowed_affiliations,
'allowed_roles': allowed_roles,
'loading_users_with_affiliation': this.loading_users_with_affiliation,
'roles': ROLES,
'users_with_affiliation': this.users_with_affiliation,
'users_with_role': this.users_with_role
}));
},
toggleForm (ev) {
ev.stopPropagation();
ev.preventDefault();
const form_class = ev.target.getAttribute('data-form');
const form = u.ancestor(ev.target, '.list-group-item').querySelector(`.${form_class}`);
if (u.hasClass('hidden', form)) {
u.removeClass('hidden', form);
} else {
u.addClass('hidden', form);
}
},
getUsersWithAffiliation () {
return this.chatroomview.model.occupants
.where({'affiliation': this.model.get('affiliation')})
.map(item => {
return {
'jid': item.get('jid'),
'nick': item.get('nick'),
'affiliation': item.get('affiliation')
}
});
},
getUsersWithRole () {
return this.chatroomview.model.occupants
.where({'role': this.model.get('role')})
.map(item => {
return {
'jid': item.get('jid'),
'nick': item.get('nick'),
'role': item.get('role')
}
});
},
queryRole (ev) {
ev.stopPropagation();
ev.preventDefault();
const data = new FormData(ev.target);
const role = data.get('role');
this.model.set({'role': null}, {'silent': true});
this.model.set({'role': role});
},
queryAffiliation (ev) {
ev.stopPropagation();
ev.preventDefault();
const data = new FormData(ev.target);
const affiliation = data.get('affiliation');
this.model.set({'affiliation': null}, {'silent': true});
this.model.set({'affiliation': affiliation});
},
assignAffiliation (ev) {
ev.stopPropagation();
ev.preventDefault();
const data = new FormData(ev.target);
const affiliation = data.get('affiliation');
const attrs = {
'jid': data.get('jid'),
'reason': data.get('reason')
}
const current_affiliation = this.model.get('affiliation');
this.chatroomview.model.setAffiliation(affiliation, [attrs])
.then(async () => {
this.alert(__('Affiliation changed'), 'primary');
await this.chatroomview.model.occupants.fetchMembers()
this.model.set({'affiliation': null}, {'silent': true});
this.model.set({'affiliation': current_affiliation});
})
.catch(err => {
this.alert(__('Sorry, something went wrong while trying to set the affiliation'), 'danger');
_converse.log(err, Strophe.LogLevel.ERROR);
});
},
assignRole (ev) {
ev.stopPropagation();
ev.preventDefault();
const data = new FormData(ev.target);
const jid = data.get('jid');
const occupant = this.chatroomview.model.getOccupant(jid);
const role = data.get('role');
const reason = data.get('reason');
const current_role = this.model.get('role');
this.chatroomview.model.setRole(occupant, role, reason,
() => {
this.alert(__('Role changed'), 'primary');
this.model.set({'role': null}, {'silent': true});
this.model.set({'role': current_role});
},
(e) => {
if (sizzle(`not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
this.alert(__('You\'re not allowed to make that change'), 'danger');
} else {
this.alert(__('Sorry, something went wrong while trying to set the role'), 'danger');
}
_converse.log(e, Strophe.LogLevel.ERROR);
}
);
}
});
_converse.ListChatRoomsModal = _converse.BootstrapModal.extend({
events: {
@ -432,7 +599,6 @@ converse.plugins.add('converse-muc-views', {
/**
* The View of an open/ongoing groupchat conversation
*
* @class
* @namespace _converse.ChatRoomView
* @memberOf _converse
@ -594,6 +760,16 @@ converse.plugins.add('converse-muc-views', {
return _converse.ChatBoxView.prototype.onKeyUp.call(this, ev);
},
showModeratorToolsModal (affiliation) {
if (_.isUndefined(this.model.modtools_modal)) {
const model = new Backbone.Model({'affiliation': affiliation});
this.modtools_modal = new _converse.ModeratorToolsModal({'model': model, 'chatroomview': this});
} else {
this.modtools_modal.set('affiliation', affiliation);
}
this.modtools_modal.show();
},
showRoomDetailsModal (ev) {
ev.preventDefault();
if (this.model.room_details_modal === undefined) {
@ -927,6 +1103,26 @@ converse.plugins.add('converse-muc-views', {
this.showErrorMessage(__("Sorry, an error happened while running the command. Check your browser's developer console for details."));
},
getAllowedCommands () {
// FIXME: The availability of some of these commands
// depend on the MUCs configuration (e.g. whether it's
// moderated or not). We need to take that into
// consideration.
let allowed_commands = ['clear', 'help', 'me', 'nick', 'subject', 'topic', 'register'];
const occupant = this.model.occupants.findWhere({'jid': _converse.bare_jid});
if (this.verifyAffiliations(['owner'], occupant, false)) {
allowed_commands = allowed_commands.concat(OWNER_COMMANDS).concat(ADMIN_COMMANDS);
} else if (this.verifyAffiliations(['admin'], occupant, false)) {
allowed_commands = allowed_commands.concat(ADMIN_COMMANDS);
}
if (this.verifyRoles(['moderator'], occupant, false)) {
allowed_commands = allowed_commands.concat(MODERATOR_COMMANDS).concat(VISITOR_COMMANDS);
} else if (!this.verifyRoles(['visitor', 'participant', 'moderator'], occupant, false)) {
allowed_commands = allowed_commands.concat(VISITOR_COMMANDS);
}
return allowed_commands;
},
parseMessageForCommands (text) {
if (_converse.muc_disable_slash_commands && !Array.isArray(_converse.muc_disable_slash_commands)) {
return _converse.ChatBoxView.prototype.parseMessageForCommands.apply(this, arguments);
@ -936,7 +1132,7 @@ converse.plugins.add('converse-muc-views', {
if (!command) {
return false;
}
const args = text.slice(('/'+command).length+1);
const args = text.slice(('/'+command).length+1).trim();
let disabled_commands = [];
if (Array.isArray(_converse.muc_disable_slash_commands)) {
@ -955,6 +1151,10 @@ converse.plugins.add('converse-muc-views', {
this.setAffiliation(command, args, ['admin', 'owner']);
break;
}
case 'modtools': {
this.showModeratorToolsModal(args);
break;
}
case 'deop': {
// FIXME: /deop only applies to setting a moderators
// role to "participant" (which only admin/owner can
@ -975,22 +1175,7 @@ converse.plugins.add('converse-muc-views', {
break;
}
case 'help': {
// FIXME: The availability of some of these commands
// depend on the MUCs configuration (e.g. whether it's
// moderated or not). We need to take that into
// consideration.
let allowed_commands = ['clear', 'help', 'me', 'nick', 'subject', 'topic', 'register'];
const occupant = this.model.occupants.findWhere({'jid': _converse.bare_jid});
if (this.verifyAffiliations(['owner'], occupant, false)) {
allowed_commands = allowed_commands.concat(OWNER_COMMANDS).concat(ADMIN_COMMANDS);
} else if (this.verifyAffiliations(['admin'], occupant, false)) {
allowed_commands = allowed_commands.concat(ADMIN_COMMANDS);
}
if (this.verifyRoles(['moderator'], occupant, false)) {
allowed_commands = allowed_commands.concat(MODERATOR_COMMANDS).concat(VISITOR_COMMANDS);
} else if (!this.verifyRoles(['visitor', 'participant', 'moderator'], occupant, false)) {
allowed_commands = allowed_commands.concat(VISITOR_COMMANDS);
}
const allowed_commands = this.getAllowedCommands();
this.showHelpMessages([`<strong>${__("You can run the following commands")}</strong>`]);
this.showHelpMessages([
`<strong>/admin</strong>: ${__("Change user's affiliation to admin")}`,
@ -1002,6 +1187,7 @@ converse.plugins.add('converse-muc-views', {
`<strong>/kick</strong>: ${__('Kick user from groupchat')}`,
`<strong>/me</strong>: ${__('Write in 3rd person')}`,
`<strong>/member</strong>: ${__('Grant membership to a user')}`,
`<strong>/modtools</strong>: ${__('Opens up the moderator tools GUI')}`,
`<strong>/mute</strong>: ${__("Remove user's ability to post messages")}`,
`<strong>/nick</strong>: ${__('Change your nickname')}`,
`<strong>/op</strong>: ${__('Grant moderator role to user')}`,

View File

@ -1112,10 +1112,11 @@ converse.plugins.add('converse-muc', {
.c("item", {'affiliation': affiliation});
const result = await _converse.api.sendIQ(iq, null, false);
if (result.getAttribute('type') === 'error') {
const err_msg = `Not allowed to fetch ${affiliation} list for MUC ${this.get('jid')}`;
const err_msg = `Error: not allowed to fetch ${affiliation} list for MUC ${this.get('jid')}`;
const err = new Error(err_msg);
_converse.log(err_msg, Strophe.LogLevel.WARN);
_converse.log(result, Strophe.LogLevel.WARN);
return null;
return err;
}
return u.parseMemberListIQ(result).filter(p => p);
},
@ -1136,8 +1137,8 @@ converse.plugins.add('converse-muc', {
async updateMemberLists (members) {
const all_affiliations = ['member', 'admin', 'owner'];
const aff_lists = await Promise.all(all_affiliations.map(a => this.getAffiliationList(a)));
const known_affiliations = all_affiliations.filter(a => aff_lists[all_affiliations.indexOf(a)] !== null);
const old_members = aff_lists.reduce((acc, val) => (val !== null ? [...val, ...acc] : acc), []);
const known_affiliations = all_affiliations.filter(a => !u.isErrorObject(aff_lists[all_affiliations.indexOf(a)]));
const old_members = aff_lists.reduce((acc, val) => (u.isErrorObject(val) ? acc: [...val, ...acc]), []);
await this.setAffiliations(u.computeAffiliationsDelta(true, false, members, old_members));
if (_converse.muc_fetch_members) {
return this.occupants.fetchMembers();
@ -1911,8 +1912,8 @@ converse.plugins.add('converse-muc', {
async fetchMembers () {
const all_affiliations = ['member', 'admin', 'owner'];
const aff_lists = await Promise.all(all_affiliations.map(a => this.chatroom.getAffiliationList(a)));
const new_members = aff_lists.reduce((acc, val) => (val !== null ? [...val, ...acc] : acc), []);
const known_affiliations = all_affiliations.filter(a => aff_lists[all_affiliations.indexOf(a)] !== null);
const new_members = aff_lists.reduce((acc, val) => (u.isErrorObject(val) ? acc : [...val, ...acc]), []);
const known_affiliations = all_affiliations.filter(a => !u.isErrorObject(aff_lists[all_affiliations.indexOf(a)]));
const new_jids = new_members.map(m => m.jid).filter(m => m !== undefined);
const new_nicks = new_members.map(m => !m.jid && m.nick || undefined).filter(m => m !== undefined);
const removed_members = this.filter(m => {

View File

@ -158,6 +158,10 @@ u.isHeadlineMessage = function (_converse, message) {
return false;
};
u.isErrorObject = function (o) {
return o instanceof Error;
}
u.isForbiddenError = function (stanza) {
if (!_.isElement(stanza)) {

View File

@ -0,0 +1,168 @@
<div class="modal" id="list-chatrooms-modal" tabindex="-1" role="dialog" aria-labelledby="list-chatrooms-modal-label" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"
id="list-chatrooms-modal-label">{{{o.__('Moderator Tools')}}}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body d-flex flex-column">
<ul class="nav nav-pills justify-content-center">
<li role="presentation" class="nav-item">
<a class="nav-link active" id="roles-tab" href="#roles-tabpanel" aria-controls="roles-tabpanel" role="tab" data-toggle="tab">Roles</a>
</li>
<li role="presentation" class="nav-item">
<a class="nav-link" id="affiliations-tab" href="#affiliations-tabpanel" aria-controls="affiliations-tabpanel" role="tab" data-toggle="tab">Affiliations</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="roles-tabpanel" role="tabpanel" aria-labelledby="roles-tab">
<form class="converse-form query-role">
<div class="form-group">
<label for="role">
<strong>{{{o.__('Role')}}}:</strong>
</label>
<div class="row">
<div class="col">
<select class="custom-select select-role" name="role">
{[ o.roles.forEach(function (role) { ]}
<option value="{{{role}}}" {[ if (role === o.role) { ]} selected="selected" {[ } ]}>{{{role}}}</option>
{[ }); ]}
</select>
</div>
<div class="col">
<input type="submit" class="btn btn-primary" name="users_with_role" value="{{{o.__('Show users')}}}"/>
</div>
</div>
</div>
</form>
<div class="scrollable-container">
<ul class="list-group list-group--users">
{[ if (o.loading_users_with_role) { ]}
<li class="list-group-item"> <span class="spinner fa fa-spinner centered"/> </li>
{[ } ]}
{[ if (o.users_with_role && o.users_with_role.length === 0) { ]}
<li class="list-group-item">{{{o.__('No users with that role found.')}}}</li>
{[ } ]}
{[ (o.users_with_role || []).forEach(function (item) { ]}
<li class="list-group-item">
<ul class="list-group">
<li class="list-group-item active">
<div><strong>JID:</strong> {{{item.jid}}}</div>
</li>
<li class="list-group-item">
<div><strong>Nickname:</strong> {{{item.nick}}}</div>
</li>
<li class="list-group-item">
<div><strong>Role:</strong> {{{item.role}}}<a href="#" data-form="role-form" class="toggle-form right fa fa-wrench"></a></div>
<form class="role-form hidden">
<div class="form-group">
<input type="hidden" name="jid" value="{{{item.jid}}}"/>
<input type="hidden" name="nick" value="{{{item.nick}}}"/>
<div class="row">
<div class="col">
<label><strong>{{{o.__('New Role')}}}:</strong></label>
<select class="custom-select select-role" name="role">
{[ o.allowed_roles.forEach(function (role) { ]}
<option value="{{{role}}}" {[ if (role === item.role) { ]} selected="selected" {[ } ]}>{{{role}}}</option>
{[ }); ]}
</select>
</div>
<div class="col">
<label><strong>{{{o.__('Reason')}}}:</strong></label>
<input class="form-control" type="text" name="reason"/>
</div>
</div>
</div>
<div class="form-group">
<input type="submit" class="btn btn-primary" value="{{{o.__('Change role')}}}"/>
</div>
</form>
</li>
</ul>
</li>
{[ }); ]}
</ul>
</div>
</div>
<div class="tab-pane" id="affiliations-tabpanel" role="tabpanel" aria-labelledby="affiliations-tab">
<form class="converse-form query-affiliation">
<div class="form-group">
<label for="affiliation">
<strong>{{{o.__('Affiliation')}}}:</strong>
</label>
<div class="row">
<div class="col">
<select class="custom-select select-affiliation" name="affiliation">
{[ o.affiliations.forEach(function (aff) { ]}
<option value="{{{aff}}}" {[ if (aff === o.affiliation) { ]} selected="selected" {[ } ]}>{{{aff}}}</option>
{[ }); ]}
</select>
</div>
<div class="col">
<input type="submit" class="btn btn-primary" name="users_with_affiliation" value="{{{o.__('Show users')}}}"/>
</div>
</div>
</div>
</form>
<ul class="list-group list-group--users">
{[ if (o.loading_users_with_affiliation) { ]}
<li class="list-group-item"> <span class="spinner fa fa-spinner centered"/> </li>
{[ } else { ]}
{[ if (o.users_with_affiliation && o.users_with_affiliation.length === 0) { ]}
<li class="list-group-item">{{{o.__('No users with that affiliation found.')}}}</li>
{[ } ]}
{[ if (o.users_with_affiliation instanceof Error) { ]}
<li class="list-group-item">{{{o.users_with_affiliation.message}}}</li>
{[ } ]}
{[ (o.users_with_affiliation || []).forEach(function (item) { ]}
<li class="list-group-item">
<ul class="list-group">
<li class="list-group-item active">
<div><strong>JID:</strong> {{{item.jid}}}</div>
</li>
<li class="list-group-item">
<div><strong>Nickname:</strong> {{{item.nick}}}</div>
</li>
<li class="list-group-item">
<div><strong>Affiliation:</strong> {{{item.affiliation}}} <a href="#" data-form="affiliation-form" class="toggle-form right fa fa-wrench"></a></div>
<form class="affiliation-form hidden">
<div class="form-group">
<input type="hidden" name="jid" value="{{{item.jid}}}"/>
<input type="hidden" name="nick" value="{{{item.nick}}}"/>
<div class="row">
<div class="col">
<label><strong>{{{o.__('New affiliation')}}}:</strong></label>
<select class="custom-select select-affiliation" name="affiliation">
{[ o.allowed_affiliations.forEach(function (aff) { ]}
<option value="{{{aff}}}" {[ if (aff === item.affiliation) { ]} selected="selected" {[ } ]}>{{{aff}}}</option>
{[ }); ]}
</select>
</div>
<div class="col">
<label><strong>{{{o.__('Reason')}}}:</strong></label>
<input class="form-control" type="text" name="reason"/>
</div>
</div>
</div>
<div class="form-group">
<input type="submit" class="btn btn-primary" name="change" value="{{{o.__('Change affiliation')}}}"/>
</div>
</form>
</li>
</ul>
</li>
{[ }); ]}
{[ } ]}
</ul>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -55,6 +55,7 @@ var specs = [
"spec/user-details-modal",
"spec/messages",
"spec/muc",
"spec/modtools",
"spec/room_registration",
"spec/autocomplete",
"spec/minchats",

View File

@ -213,51 +213,71 @@
};
utils.returnMemberLists = async function (_converse, muc_jid, members=[]) {
utils.returnMemberLists = async function (_converse, muc_jid, members=[], affiliations=['member', 'owner', 'admin']) {
const stanzas = _converse.connection.IQ_stanzas;
const member_IQ = await u.waitUntil(() => _.filter(
stanzas,
s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="member"]`, s).length
).pop());
const member_list_stanza = $iq({
'from': 'coven@chat.shakespeare.lit',
'id': member_IQ.getAttribute('id'),
'to': 'romeo@montague.lit/orchard',
'type': 'result'
}).c('query', {'xmlns': Strophe.NS.MUC_ADMIN});
members.forEach(member => {
member_list_stanza.c('item', {
'affiliation': 'member',
'jid': 'hag66@shakespeare.lit',
'nick': member,
'role': 'participant'
if (affiliations.includes('member')) {
const member_IQ = await u.waitUntil(() => _.filter(
stanzas,
s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="member"]`, s).length
).pop());
const member_list_stanza = $iq({
'from': 'coven@chat.shakespeare.lit',
'id': member_IQ.getAttribute('id'),
'to': 'romeo@montague.lit/orchard',
'type': 'result'
}).c('query', {'xmlns': Strophe.NS.MUC_ADMIN});
members.filter(m => m.affiliation === 'member').forEach(m => {
member_list_stanza.c('item', {
'affiliation': m.affiliation,
'jid': m.jid,
'nick': m.nick
});
});
});
_converse.connection._dataRecv(utils.createRequest(member_list_stanza));
_converse.connection._dataRecv(utils.createRequest(member_list_stanza));
}
const admin_IQ = await u.waitUntil(() => _.filter(
stanzas,
s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="admin"]`, s).length
).pop());
const admin_list_stanza = $iq({
'from': 'coven@chat.shakespeare.lit',
'id': admin_IQ.getAttribute('id'),
'to': 'romeo@montague.lit/orchard',
'type': 'result'
}).c('query', {'xmlns': Strophe.NS.MUC_ADMIN});
_converse.connection._dataRecv(utils.createRequest(admin_list_stanza));
if (affiliations.includes('admin')) {
const admin_IQ = await u.waitUntil(() => _.filter(
stanzas,
s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="admin"]`, s).length
).pop());
const admin_list_stanza = $iq({
'from': 'coven@chat.shakespeare.lit',
'id': admin_IQ.getAttribute('id'),
'to': 'romeo@montague.lit/orchard',
'type': 'result'
}).c('query', {'xmlns': Strophe.NS.MUC_ADMIN});
members.filter(m => m.affiliation === 'admin').forEach(m => {
admin_list_stanza.c('item', {
'affiliation': m.affiliation,
'jid': m.jid,
'nick': m.nick
});
});
_converse.connection._dataRecv(utils.createRequest(admin_list_stanza));
}
const owner_IQ = await u.waitUntil(() => _.filter(
stanzas,
s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="owner"]`, s).length
).pop());
const owner_list_stanza = $iq({
'from': 'coven@chat.shakespeare.lit',
'id': owner_IQ.getAttribute('id'),
'to': 'romeo@montague.lit/orchard',
'type': 'result'
}).c('query', {'xmlns': Strophe.NS.MUC_ADMIN});
_converse.connection._dataRecv(utils.createRequest(owner_list_stanza));
if (affiliations.includes('owner')) {
const owner_IQ = await u.waitUntil(() => _.filter(
stanzas,
s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="owner"]`, s).length
).pop());
const owner_list_stanza = $iq({
'from': 'coven@chat.shakespeare.lit',
'id': owner_IQ.getAttribute('id'),
'to': 'romeo@montague.lit/orchard',
'type': 'result'
}).c('query', {'xmlns': Strophe.NS.MUC_ADMIN});
members.filter(m => m.affiliation === 'owner').forEach(m => {
owner_list_stanza.c('item', {
'affiliation': m.affiliation,
'jid': m.jid,
'nick': m.nick
});
});
_converse.connection._dataRecv(utils.createRequest(owner_list_stanza));
}
};
utils.receiveOwnMUCPresence = function (_converse, muc_jid, nick) {