Commit aee6a192 authored by JC Brand's avatar JC Brand

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.
parent a03e722a
# 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
......
......@@ -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;
}
......
......@@ -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 {
......
#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;
......
(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();
}));
});
}));
......@@ -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 @@
});
});
}));
......@@ -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);
},
......
......@@ -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();
......
This diff is collapsed.
......@@ -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 => {
......
......@@ -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)) {
......
This diff is collapsed.
......@@ -55,6 +55,7 @@ var specs = [
"spec/user-details-modal",
"spec/messages",
"spec/muc",
"spec/modtools",
"spec/room_registration",
"spec/autocomplete",
"spec/minchats",
......
......@@ -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) {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment