Sfoglia il codice sorgente

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.
JC Brand 6 anni fa
parent
commit
aee6a192d1

+ 5 - 0
CHANGES.md

@@ -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

+ 0 - 1
sass/_core.scss

@@ -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;
     }
 

+ 1 - 2
sass/_forms.scss

@@ -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 {

+ 15 - 1
sass/_modal.scss

@@ -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 - 0
spec/modtools.js

@@ -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();
+        }));
+    });
+}));

+ 13 - 7
spec/muc.js

@@ -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 @@
         });
     });
 }));
-
-

+ 1 - 8
src/converse-chatview.js

@@ -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);
             },

+ 33 - 1
src/converse-modal.js

@@ -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();

+ 205 - 19
src/converse-muc-views.js

@@ -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')}`,

+ 7 - 6
src/headless/converse-muc.js

@@ -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 => {

+ 4 - 0
src/headless/utils/core.js

@@ -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)) {

+ 168 - 0
src/templates/moderator_tools_modal.html

@@ -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>

+ 1 - 0
tests/runner.js

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

+ 62 - 42
tests/utils.js

@@ -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));
-
-        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('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));
+        }
+
+        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) {