From 0242fdb02076752e515466089de0464f344fb7e8 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Tue, 27 Jul 2021 12:01:12 +0200 Subject: [PATCH] Extract moderator tools functionality and put it in a component This makes it easier for 3rd parties to embed it in other modals (besides the bootstrap modal). --- .../plugins/muc/affiliations/utils.js | 30 ++- src/headless/plugins/muc/index.js | 1 + src/headless/plugins/muc/utils.js | 21 ++ src/plugins/chatview/heading.js | 2 +- src/plugins/muc-views/index.js | 1 - .../muc-views/modals/moderator-tools.js | 194 +-------------- .../modals/templates/moderator-tools.js | 19 ++ src/plugins/muc-views/modtools.js | 225 ++++++++++++++++++ .../muc-views/templates/moderator-tools.js | 163 ++++++------- src/plugins/muc-views/tests/modtools.js | 19 +- webpack.html | 4 +- 11 files changed, 385 insertions(+), 294 deletions(-) create mode 100644 src/headless/plugins/muc/utils.js create mode 100644 src/plugins/muc-views/modals/templates/moderator-tools.js create mode 100644 src/plugins/muc-views/modtools.js diff --git a/src/headless/plugins/muc/affiliations/utils.js b/src/headless/plugins/muc/affiliations/utils.js index fe6faedd7..7f6fdf508 100644 --- a/src/headless/plugins/muc/affiliations/utils.js +++ b/src/headless/plugins/muc/affiliations/utils.js @@ -2,10 +2,11 @@ * @copyright The Converse.js contributors * @license Mozilla Public License (MPLv2) */ +import { AFFILIATIONS } from '@converse/headless/plugins/muc/index.js'; import difference from 'lodash-es/difference'; import indexOf from 'lodash-es/indexOf'; import log from "@converse/headless/log"; -import { api, converse } from '@converse/headless/core.js'; +import { _converse, api, converse } from '@converse/headless/core.js'; import { parseMemberListIQ } from '../parsers.js'; const { Strophe, $iq, u } = converse.env; @@ -20,19 +21,20 @@ const { Strophe, $iq, u } = converse.env; * @returns { Promise } */ export async function getAffiliationList (affiliation, muc_jid) { + const { __ } = _converse; const iq = $iq({ 'to': muc_jid, 'type': 'get' }) .c('query', { xmlns: Strophe.NS.MUC_ADMIN }) .c('item', { 'affiliation': affiliation }); const result = await api.sendIQ(iq, null, false); if (result === null) { - const err_msg = `Error: timeout while fetching ${affiliation} list for MUC ${muc_jid}`; + const err_msg = __('Error: timeout while fetching %1s list for MUC %2s', affiliation, muc_jid); const err = new Error(err_msg); log.warn(err_msg); log.warn(result); return err; } if (u.isErrorStanza(result)) { - const err_msg = `Error: not allowed to fetch ${affiliation} list for MUC ${muc_jid}`; + const err_msg = __('Error: not allowed to fetch %1s list for MUC %2s', affiliation, muc_jid); const err = new Error(err_msg); log.warn(err_msg); log.warn(result); @@ -43,6 +45,28 @@ export async function getAffiliationList (affiliation, muc_jid) { .sort((a, b) => (a.nick < b.nick ? -1 : a.nick > b.nick ? 1 : 0)); } +/** + * Given an occupant model, see which affiliations may be assigned to that user. + * @param { Model } occupant + * @returns { ('owner', 'admin', 'member', 'outcast', 'none')[] } - An array of assignable affiliations + */ +export function getAssignableAffiliations (occupant) { + let disabled = api.settings.get('modtools_disable_assign'); + if (!Array.isArray(disabled)) { + disabled = disabled ? AFFILIATIONS : []; + } + if (occupant.get('affiliation') === 'owner') { + return AFFILIATIONS.filter(a => !disabled.includes(a)); + } else if (occupant.get('affiliation') === 'admin') { + return AFFILIATIONS.filter(a => !['owner', 'admin', ...disabled].includes(a)); + } else { + return []; + } +} + +// Necessary for tests +_converse.getAssignableAffiliations = getAssignableAffiliations; + /** * Send IQ stanzas to the server to modify affiliations for users in this groupchat. * See: https://xmpp.org/extensions/xep-0045.html#modifymember diff --git a/src/headless/plugins/muc/index.js b/src/headless/plugins/muc/index.js index 86c08fb32..d0228468d 100644 --- a/src/headless/plugins/muc/index.js +++ b/src/headless/plugins/muc/index.js @@ -236,6 +236,7 @@ converse.plugins.add('converse-muc', { 'auto_register_muc_nickname': false, 'hide_muc_participants': false, 'locked_muc_domain': false, + 'modtools_disable_assign': false, 'muc_clear_messages_on_leave': true, 'muc_domain': undefined, 'muc_fetch_members': true, diff --git a/src/headless/plugins/muc/utils.js b/src/headless/plugins/muc/utils.js new file mode 100644 index 000000000..7f4c94d8e --- /dev/null +++ b/src/headless/plugins/muc/utils.js @@ -0,0 +1,21 @@ +import { ROLES } from '@converse/headless/plugins/muc/index.js'; +import { _converse, api } from '@converse/headless/core.js'; + +/** + * Given an occupant model, see which roles may be assigned to that user. + * @param { Model } occupant + * @returns { ('moderator', 'participant', 'visitor')[] } - An array of assignable roles + */ +export function getAssignableRoles (occupant) { + let disabled = api.settings.get('modtools_disable_assign'); + if (!Array.isArray(disabled)) { + disabled = disabled ? ROLES : []; + } + if (occupant.get('role') === 'moderator') { + return ROLES.filter(r => !disabled.includes(r)); + } else { + return []; + } +} + +Object.assign(_converse, { getAssignableRoles }); diff --git a/src/plugins/chatview/heading.js b/src/plugins/chatview/heading.js index 10c5d51a3..33ac2b833 100644 --- a/src/plugins/chatview/heading.js +++ b/src/plugins/chatview/heading.js @@ -4,7 +4,7 @@ import { CustomElement } from 'shared/components/element.js'; import { __ } from 'i18n'; import { _converse, api } from "@converse/headless/core"; -import './styles//chat-head.scss'; +import './styles/chat-head.scss'; export default class ChatHeading extends CustomElement { diff --git a/src/plugins/muc-views/index.js b/src/plugins/muc-views/index.js index da0ab9c37..561df1aee 100644 --- a/src/plugins/muc-views/index.js +++ b/src/plugins/muc-views/index.js @@ -44,7 +44,6 @@ converse.plugins.add('converse-muc-views', { 'cache_muc_messages': true, 'locked_muc_nickname': false, 'modtools_disable_query': [], - 'modtools_disable_assign': false, 'muc_disable_slash_commands': false, 'muc_mention_autocomplete_filter': 'contains', 'muc_mention_autocomplete_min_chars': 0, diff --git a/src/plugins/muc-views/modals/moderator-tools.js b/src/plugins/muc-views/modals/moderator-tools.js index 5bd29a756..f9d09e3e8 100644 --- a/src/plugins/muc-views/modals/moderator-tools.js +++ b/src/plugins/muc-views/modals/moderator-tools.js @@ -1,14 +1,6 @@ +import '../modtools.js'; import BootstrapModal from "modals/base.js"; -import log from "@converse/headless/log"; -import tpl_moderator_tools_modal from "../templates/moderator-tools.js"; -import { AFFILIATIONS, ROLES } from "@converse/headless/plugins/muc/index.js"; -import { __ } from 'i18n'; -import { _converse, api, converse } from "@converse/headless/core"; -import { getAffiliationList, setAffiliation } from '@converse/headless/plugins/muc/affiliations/utils.js' - -const { Strophe, sizzle } = converse.env; -const u = converse.env.utils; - +import tpl_moderator_tools from './templates/moderator-tools.js'; const ModeratorToolsModal = BootstrapModal.extend({ id: "converse-modtools-modal", @@ -17,190 +9,10 @@ const ModeratorToolsModal = BootstrapModal.extend({ initialize (attrs) { this.muc = attrs.muc; BootstrapModal.prototype.initialize.apply(this, arguments); - - this.affiliations_filter = ''; - this.roles_filter = ''; - - this.listenTo(this.model, 'change:role', () => { - this.users_with_role = this.muc.getOccupantsWithRole(this.model.get('role')); - this.render(); - }); - this.listenTo(this.model, 'change:affiliation', async () => { - this.loading_users_with_affiliation = true; - this.users_with_affiliation = null; - this.render(); - const affiliation = this.model.get('affiliation'); - if (this.shouldFetchAffiliationsList()) { - const muc_jid = this.muc.get('jid'); - this.users_with_affiliation = await getAffiliationList(affiliation, muc_jid); - } else { - this.users_with_affiliation = this.muc.getOccupantsWithAffiliation(affiliation); - } - this.loading_users_with_affiliation = false; - this.render(); - }); }, toHTML () { - const occupant = this.muc.occupants.findWhere({'jid': _converse.bare_jid}); - return tpl_moderator_tools_modal(Object.assign(this.model.toJSON(), { - 'affiliations_filter': this.affiliations_filter, - 'assignAffiliation': ev => this.assignAffiliation(ev), - 'assignRole': ev => this.assignRole(ev), - 'assignable_affiliations': this.getAssignableAffiliations(occupant), - 'assignable_roles': this.getAssignableRoles(occupant), - 'filterAffiliationResults': ev => this.filterAffiliationResults(ev), - 'filterRoleResults': ev => this.filterRoleResults(ev), - 'loading_users_with_affiliation': this.loading_users_with_affiliation, - 'queryAffiliation': ev => this.queryAffiliation(ev), - 'queryRole': ev => this.queryRole(ev), - 'queryable_affiliations': AFFILIATIONS.filter(a => !_converse.modtools_disable_query.includes(a)), - 'queryable_roles': ROLES.filter(a => !_converse.modtools_disable_query.includes(a)), - 'roles_filter': this.roles_filter, - 'switchTab': ev => this.switchTab(ev), - 'toggleForm': ev => this.toggleForm(ev), - 'users_with_affiliation': this.users_with_affiliation, - 'users_with_role': this.users_with_role - })); - }, - - getAssignableAffiliations (occupant) { - let disabled = api.settings.get('modtools_disable_assign'); - if (!Array.isArray(disabled)) { - disabled = disabled ? AFFILIATIONS : []; - } - - if (occupant.get('affiliation') === 'owner') { - return AFFILIATIONS.filter(a => !disabled.includes(a)); - } else if (occupant.get('affiliation') === 'admin') { - return AFFILIATIONS.filter(a => !['owner', 'admin', ...disabled].includes(a)); - } else { - return []; - } - }, - - getAssignableRoles (occupant) { - let disabled = api.settings.get('modtools_disable_assign'); - if (!Array.isArray(disabled)) { - disabled = disabled ? ROLES : []; - } - - if (occupant.get('role') === 'moderator') { - return ROLES.filter(r => !disabled.includes(r)); - } else { - return []; - } - }, - - shouldFetchAffiliationsList () { - const affiliation = this.model.get('affiliation'); - if (affiliation === 'none') { - return false; - } - const chatroom = this.muc; - const auto_fetched_affs = chatroom.occupants.getAutoFetchedAffiliationLists(); - if (auto_fetched_affs.includes(affiliation)) { - return false; - } else { - return true; - } - }, - - 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); - } - }, - - filterRoleResults (ev) { - this.roles_filter = ev.target.value; - this.render(); - }, - - filterAffiliationResults (ev) { - this.affiliations_filter = ev.target.value; - this.render(); - }, - - 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}); - }, - - async 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'); - const muc_jid = this.muc.get('jid'); - try { - await setAffiliation(affiliation, muc_jid, [attrs]); - } catch (e) { - if (e === null) { - this.alert(__('Timeout error while trying to set the affiliation'), 'danger'); - } else if (sizzle(`not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, e).length) { - this.alert(__('Sorry, you\'re not allowed to make that change'), 'danger'); - } else { - this.alert(__('Sorry, something went wrong while trying to set the affiliation'), 'danger'); - } - log.error(e); - return; - } - this.alert(__('Affiliation changed'), 'primary'); - await this.muc.occupants.fetchMembers() - this.model.set({'affiliation': null}, {'silent': true}); - this.model.set({'affiliation': current_affiliation}); - }, - - assignRole (ev) { - ev.stopPropagation(); - ev.preventDefault(); - const data = new FormData(ev.target); - const occupant = this.muc.getOccupant(data.get('jid') || data.get('nick')); - const role = data.get('role'); - const reason = data.get('reason'); - const current_role = this.model.get('role'); - this.muc.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'); - if (u.isErrorObject(e)) { - log.error(e); - } - } - } - ); + return tpl_moderator_tools(this); } }); diff --git a/src/plugins/muc-views/modals/templates/moderator-tools.js b/src/plugins/muc-views/modals/templates/moderator-tools.js new file mode 100644 index 000000000..3ed2c2c61 --- /dev/null +++ b/src/plugins/muc-views/modals/templates/moderator-tools.js @@ -0,0 +1,19 @@ +import { __ } from 'i18n'; +import { html } from "lit"; +import { modal_header_close_button } from "modals/templates/buttons.js" + +export default (o) => { + const i18n_moderator_tools = __('Moderator Tools'); + return html` + `; +} diff --git a/src/plugins/muc-views/modtools.js b/src/plugins/muc-views/modtools.js new file mode 100644 index 000000000..a14727ad0 --- /dev/null +++ b/src/plugins/muc-views/modtools.js @@ -0,0 +1,225 @@ +import log from '@converse/headless/log'; +import tpl_moderator_tools from './templates/moderator-tools.js'; +import { AFFILIATIONS, ROLES } from '@converse/headless/plugins/muc/index.js'; +import { CustomElement } from 'shared/components/element.js'; +import { __ } from 'i18n'; +import { _converse, api, converse } from '@converse/headless/core'; +import { getAssignableRoles } from '@converse/headless/plugins/muc/utils.js'; +import { + getAffiliationList, + getAssignableAffiliations, + setAffiliation, +} from '@converse/headless/plugins/muc/affiliations/utils.js'; + +const { Strophe, sizzle, u } = converse.env; + +export default class ModeratorTools extends CustomElement { + static get properties () { + return { + affiliations_filter: { type: String, attribute: false }, + alert_message: { type: String, attribute: false }, + alert_type: { type: String, attribute: false }, + model: { type: Object }, + muc: { type: Object }, + roles_filter: { type: String, attribute: false }, + users_with_affiliation: { type: Array, attribute: false }, + users_with_role: { type: Array, attribute: false }, + }; + } + + constructor () { + super(); + this.affiliations_filter = ''; + this.roles_filter = ''; + } + + connectedCallback () { + super.connectedCallback(); + this.initialize(); + } + + initialize () { + this.listenTo(this.model, 'change:role', this.onSearchRoleChange, this); + this.listenTo(this.model, 'change:affiliation', this.onSearchAffiliationChange, this); + } + + render () { + const occupant = this.muc.occupants.findWhere({ 'jid': _converse.bare_jid }); + return tpl_moderator_tools( + Object.assign(this.model.toJSON(), { + 'affiliations_filter': this.affiliations_filter, + 'alert_message': this.alert_message, + 'alert_type': this.alert_type, + 'assignAffiliation': ev => this.assignAffiliation(ev), + 'assignRole': ev => this.assignRole(ev), + 'assignable_affiliations': getAssignableAffiliations(occupant), + 'assignable_roles': getAssignableRoles(occupant), + 'filterAffiliationResults': ev => this.filterAffiliationResults(ev), + 'filterRoleResults': ev => this.filterRoleResults(ev), + 'loading_users_with_affiliation': this.loading_users_with_affiliation, + 'queryAffiliation': ev => this.queryAffiliation(ev), + 'queryRole': ev => this.queryRole(ev), + 'queryable_affiliations': AFFILIATIONS.filter(a => !api.settings.get('modtools_disable_query').includes(a)), + 'queryable_roles': ROLES.filter(a => !api.settings.get('modtools_disable_query').includes(a)), + 'roles_filter': this.roles_filter, + 'switchTab': ev => this.switchTab(ev), + 'toggleForm': ev => this.toggleForm(ev), + 'users_with_affiliation': this.users_with_affiliation, + 'users_with_role': this.users_with_role, + }) + ); + } + + async onSearchAffiliationChange () { + this.clearAlert(); + this.loading_users_with_affiliation = true; + this.users_with_affiliation = null; + + const affiliation = this.model.get('affiliation'); + if (this.shouldFetchAffiliationsList()) { + const muc_jid = this.muc.get('jid'); + const result = await getAffiliationList(affiliation, muc_jid); + if (result instanceof Error) { + this.alert(result.message, 'danger'); + this.users_with_affiliation = []; + } else { + this.users_with_affiliation = result; + } + } else { + this.users_with_affiliation = this.muc.getOccupantsWithAffiliation(affiliation); + } + this.loading_users_with_affiliation = false; + } + + onSearchRoleChange () { + this.clearAlert(); + this.users_with_role = this.muc.getOccupantsWithRole(this.model.get('role')); + } + + shouldFetchAffiliationsList () { + const affiliation = this.model.get('affiliation'); + if (affiliation === 'none') { + return false; + } + const chatroom = this.muc; + const auto_fetched_affs = chatroom.occupants.getAutoFetchedAffiliationLists(); + if (auto_fetched_affs.includes(affiliation)) { + return false; + } else { + return true; + } + } + + toggleForm (ev) { // eslint-disable-line class-methods-use-this + 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); + } + } + + filterRoleResults (ev) { + this.roles_filter = ev.target.value; + this.render(); + } + + filterAffiliationResults (ev) { + this.affiliations_filter = ev.target.value; + } + + 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 }); + } + + alert (message, type) { + this.alert_message = message; + this.alert_type = type; + } + + clearAlert () { + this.alert_message = undefined; + this.alert_type = undefined; + } + + async assignAffiliation (ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.clearAlert(); + 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'); + const muc_jid = this.muc.get('jid'); + try { + await setAffiliation(affiliation, muc_jid, [attrs]); + } catch (e) { + if (e === null) { + this.alert(__('Timeout error while trying to set the affiliation'), 'danger'); + } else if (sizzle(`not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, e).length) { + this.alert(__("Sorry, you're not allowed to make that change"), 'danger'); + } else { + this.alert(__('Sorry, something went wrong while trying to set the affiliation'), 'danger'); + } + log.error(e); + return; + } + await this.muc.occupants.fetchMembers(); + this.model.set({ 'affiliation': null }, { 'silent': true }); + this.model.set({ 'affiliation': current_affiliation }); + this.alert(__('Affiliation changed'), 'primary'); + } + + assignRole (ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.clearAlert(); + const data = new FormData(ev.target); + const occupant = this.muc.getOccupant(data.get('jid') || data.get('nick')); + const role = data.get('role'); + const reason = data.get('reason'); + const current_role = this.model.get('role'); + this.muc.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'); + if (u.isErrorObject(e)) { + log.error(e); + } + } + } + ); + } +} + +api.elements.define('converse-modtools', ModeratorTools); diff --git a/src/plugins/muc-views/templates/moderator-tools.js b/src/plugins/muc-views/templates/moderator-tools.js index f1711e872..0ae66bddf 100644 --- a/src/plugins/muc-views/templates/moderator-tools.js +++ b/src/plugins/muc-views/templates/moderator-tools.js @@ -1,7 +1,6 @@ import spinner from "templates/spinner.js"; import { __ } from 'i18n'; import { html } from "lit"; -import { modal_header_close_button } from "modals/templates/buttons.js" function getRoleHelpText (role) { @@ -135,13 +134,13 @@ const affiliation_list_item = (o) => html` `; -const tpl_navigation = (o) => html` +const tpl_navigation = () => html` `; @@ -149,7 +148,6 @@ const tpl_navigation = (o) => html` export default (o) => { const i18n_affiliation = __('Affiliation'); - const i18n_moderator_tools = __('Moderator Tools'); const i18n_no_users_with_aff = __('No users with that affiliation found.') const i18n_no_users_with_role = __('No users with that role found.'); const i18n_filter = __('Type here to filter the search results'); @@ -167,94 +165,83 @@ export default (o) => { ); const show_both_tabs = o.queryable_roles.length && o.queryable_affiliations.length; return html` -