diff --git a/src/headless/headless.js b/src/headless/headless.js index 86263a705c..377f468fad 100644 --- a/src/headless/headless.js +++ b/src/headless/headless.js @@ -18,6 +18,7 @@ import "./plugins/roster/index.js"; // RFC-6121 Contacts Roster import "./plugins/smacks/index.js"; // XEP-0198 Stream Management import "./plugins/status/index.js"; import "./plugins/vcard/index.js"; // XEP-0054 VCard-temp +import "./plugins/blocking/index.js"; // XEP-0191 Blocking Command /* END: Removable components */ import { converse } from "./core.js"; diff --git a/src/headless/plugins/blocking/api.js b/src/headless/plugins/blocking/api.js new file mode 100644 index 0000000000..3061aa603c --- /dev/null +++ b/src/headless/plugins/blocking/api.js @@ -0,0 +1,148 @@ +import log from '@converse/headless/log.js'; +import { _converse, api, converse } from "@converse/headless/core.js"; + +const { Strophe, $iq, sizzle, u } = converse.env; + +export default { + /** + * Retrieves the blocklist held by the logged in user at a JID by sending an IQ stanza. + * Saves the model variable _converse.blocked.set + * @private + * @method api.refreshBlocklist + */ + async refreshBlocklist () { + const features = await api.disco.getFeatures(_converse.domain); + if (!features?.findWhere({'var': Strophe.NS.BLOCKING})) { + return false; + } + if (!_converse.connection) { + return false; + } + + const iq = $iq({ + 'type': 'get', + 'id': u.getUniqueId('blocklist') + }).c('blocklist', {'xmlns': Strophe.NS.BLOCKING}); + + const result = await api.sendIQ(iq).catch(e => { log.fatal(e); return null }); + if (result === null) { + const err_msg = `An error occured while fetching the blocklist`; + api.alert('error', __('Error'), err_msg); + log(err_msg, Strophe.LogLevel.WARN); + return false; + } else if (u.isErrorStanza(result)) { + log.error(`Error while fetching blocklist from ${jid}`); + log.error(result); + return false; + } + + const blocklist = sizzle('item', result).map(item => item.getAttribute('jid')); + _converse.blocked.set({'set': new Set(blocklist)}); + return true; + }, + + /** + * Handle incoming iq stanzas in the BLOCKING namespace. Adjusts the global blocked_set. + * @private + * @method api.handleBlockingStanza + * @param { Object } [stanza] - The incoming stanza to handle + */ + async handleBlockingStanza ( stanza ) { + if (stanza.firstElementChild.tagName === 'block') { + const users_to_block = sizzle('item', stanza).map(item => item.getAttribute('jid')); + users_to_block.forEach(_converse.blocked.get('set').add, _converse.blocked.get('set')); + } else if (stanza.firstElementChild.tagName === 'unblock') { + const users_to_unblock = sizzle('item', stanza).map(item => item.getAttribute('jid')); + users_to_unblock.forEach(_converse.blocked.get('set').delete, _converse.blocked.get('set')); + } else { + log.error("Received blocklist push update but could not interpret it."); + } + // TODO: Fix this to not use the length as an update key, and + // use a more accurate update method, like a length-extendable hash + _converse.blocked.set({ 'len': _converse.blocked.get('set').size }); + }, + + /** + * Blocks JIDs by sending an IQ stanza + * @method api.blockUser + * + * @param { Array } [jid_list] - The list of JIDs to block + */ + async blockUser ( jid_list ) { + if (!_converse.disco_entities.get(_converse.domain)?.features?.findWhere({'var': Strophe.NS.BLOCKING})) { + return false; + } + if (!_converse.connection) { + return false; + } + + const block_items = jid_list.map(jid => Strophe.xmlElement('item', { 'jid': jid })); + const block_element = Strophe.xmlElement('block', {'xmlns': Strophe.NS.BLOCKING }); + + block_items.forEach(block_element.appendChild, block_element); + + const iq = $iq({ + 'type': 'set', + 'id': u.getUniqueId('block') + }).cnode(block_element); + + const result = await api.sendIQ(iq).catch(e => { log.fatal(e); return false }); + const err_msg = `An error occured while trying to block user(s) ${jid_list}`; + if (result === null) { + api.alert('error', __('Error'), err_msg); + log(err_msg, Strophe.LogLevel.WARN); + return false; + } else if (u.isErrorStanza(result)) { + log.error(err_msg); + log.error(result); + return false; + } + return true; + }, + + /** + * Unblocks JIDs by sending an IQ stanza to the server JID specified + * @method api.unblockUser + * @param { Array } [jid_list] - The list of JIDs to unblock + */ + async unblockUser ( jid_list ) { + if (!_converse.disco_entities.get(_converse.domain)?.features?.findWhere({'var': Strophe.NS.BLOCKING})) { + return false; + } + if (!_converse.connection) { + return false; + } + + const unblock_items = jid_list.map(jid => Strophe.xmlElement('item', { 'jid': jid })); + const unblock_element = Strophe.xmlElement('unblock', {'xmlns': Strophe.NS.BLOCKING}); + + unblock_items.forEach(unblock_element.append, unblock_element); + + const iq = $iq({ + 'type': 'set', + 'id': u.getUniqueId('block') + }).cnode(unblock_element); + + const result = await api.sendIQ(iq).catch(e => { log.fatal(e); return false }); + const err_msg = `An error occured while trying to unblock user(s) ${jid_list}`; + if (result === null) { + api.alert('error', __('Error'), err_msg); + log(err_msg, Strophe.LogLevel.WARN); + return false; + } else if (u.isErrorStanza(result)) { + log.error(err_msg); + log.error(result); + return false; + } + return true; + }, + + /** + * Retrieved the blocked set + * @method api.blockedUsers + */ + blockedUsers () { + return _converse.blocked.get('set'); + } + +} diff --git a/src/headless/plugins/blocking/index.js b/src/headless/plugins/blocking/index.js new file mode 100644 index 0000000000..74bdb4c324 --- /dev/null +++ b/src/headless/plugins/blocking/index.js @@ -0,0 +1,39 @@ +/** + * @description + * Converse.js plugin which adds support for XEP-0191: Blocking + * Allows users to block other users, which hides their messages + */ +import blocking_api from './api.js'; +import { _converse, api, converse } from "@converse/headless/core.js"; +import { onConnected } from './utils.js'; +import { Model } from '@converse/skeletor/src/model.js'; + +const { Strophe } = converse.env; + +const SetModel = Model.extend({ + defaults: { + 'set': new Set(), + 'len': 0, + } +}); + +Strophe.addNamespace('BLOCKING', "urn:xmpp:blocking"); + +converse.plugins.add('converse-blocking', { + enabled (_converse) { + return ( + !_converse.api.settings.get('blacklisted_plugins').includes('converse-blocking') + ); + }, + + + dependencies: ["converse-disco"], + + initialize () { + _converse.blocked = new SetModel(); + Object.assign(api, blocking_api); + + api.listen.on('discoInitialized', onConnected); + api.listen.on('reconnected', onConnected); + } +}); diff --git a/src/headless/plugins/blocking/utils.js b/src/headless/plugins/blocking/utils.js new file mode 100644 index 0000000000..22fac63735 --- /dev/null +++ b/src/headless/plugins/blocking/utils.js @@ -0,0 +1,10 @@ +import { _converse, api, converse } from "@converse/headless/core.js"; + +const { Strophe, $iq } = converse.env; + +export async function onConnected () { + api.refreshBlocklist(); + _converse.connection.addHandler(api.handleBlockingStanza, Strophe.NS.BLOCKING, 'iq', 'set', null, null); +} + + diff --git a/src/headless/plugins/chat/model.js b/src/headless/plugins/chat/model.js index cbdff51f18..57211e35b6 100644 --- a/src/headless/plugins/chat/model.js +++ b/src/headless/plugins/chat/model.js @@ -1099,7 +1099,11 @@ const ChatBox = ModelWithContact.extend({ // when the user writes a message as opposed to when a // message is received. this.ui.set('scrolled', false); - } else if (this.isHidden()) { + } else if ( this.isHidden() || + ( _converse.pluggable.plugins['converse.blocking'] && + api.blockedUsers()?.has(message?.get('from_real_jid')) + ) + ) { this.incrementUnreadMsgsCounter(message); } else { this.sendMarkerForMessage(message); diff --git a/src/headless/plugins/muc/muc.js b/src/headless/plugins/muc/muc.js index 2cb8e3932b..faa330bc70 100644 --- a/src/headless/plugins/muc/muc.js +++ b/src/headless/plugins/muc/muc.js @@ -1324,6 +1324,12 @@ const ChatRoomMixin = { getAllowedCommands () { let allowed_commands = ['clear', 'help', 'me', 'nick', 'register']; + // Only allow blocking commands when server supports it and we also support it + if ( _converse.disco_entities.get(_converse.domain, true)?.features?.findWhere({'var': Strophe.NS.BLOCKING}) && + ( _converse.pluggable.plugins['converse-blocking']?.enabled(_converse) ) + ) { + allowed_commands = [...allowed_commands, ...['block', 'unblock']]; + } if (this.config.get('changesubject') || ['owner', 'admin'].includes(this.getOwnAffiliation())) { allowed_commands = [...allowed_commands, ...['subject', 'topic']]; } diff --git a/src/headless/shared/constants.js b/src/headless/shared/constants.js index decf3a2b72..d019730bdf 100644 --- a/src/headless/shared/constants.js +++ b/src/headless/shared/constants.js @@ -35,7 +35,8 @@ export const CORE_PLUGINS = [ 'converse-roster', 'converse-smacks', 'converse-status', - 'converse-vcard' + 'converse-vcard', + 'converse-blocking' ]; export const URL_PARSE_OPTIONS = { 'start': /(\b|_)(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi }; diff --git a/src/plugins/muc-views/modals/occupant.js b/src/plugins/muc-views/modals/occupant.js index f623fe1a81..cdf8e369b6 100644 --- a/src/plugins/muc-views/modals/occupant.js +++ b/src/plugins/muc-views/modals/occupant.js @@ -1,5 +1,5 @@ import BaseModal from "plugins/modal/modal.js"; -import tpl_occupant_modal from "./templates/occupant.js"; +import { tpl_occupant_modal, tpl_footer } from "./templates/occupant.js"; import { _converse, api } from "@converse/headless/core"; import { Model } from '@converse/skeletor/src/model.js'; @@ -9,6 +9,9 @@ export default class OccupantModal extends BaseModal { super.initialize() const model = this.model ?? this.message; this.listenTo(model, 'change', () => this.render()); + if ( _converse.pluggable.plugins['converse-blocking']?.enabled(_converse) ) { + this.listenTo(_converse.blocked, 'change', this.render); + } /** * Triggered once the OccupantModal has been initialized * @event _converse#occupantModalInitialized @@ -28,9 +31,17 @@ export default class OccupantModal extends BaseModal { } renderModal () { + const model = this.model ?? this.message; + if (model?.collection?.chatroom) { + this.listenToOnce(model.collection.chatroom, 'change', () => this.render()); + } return tpl_occupant_modal(this); } + renderModalFooter () { + return tpl_footer(this); + } + getModalTitle () { const model = this.model ?? this.message; return model?.getDisplayName(); diff --git a/src/plugins/muc-views/modals/templates/occupant.js b/src/plugins/muc-views/modals/templates/occupant.js index 2c248811e3..2064545a0a 100644 --- a/src/plugins/muc-views/modals/templates/occupant.js +++ b/src/plugins/muc-views/modals/templates/occupant.js @@ -2,10 +2,168 @@ import 'shared/avatar/avatar.js'; import { __ } from 'i18n'; import { html } from "lit"; import { until } from 'lit/directives/until.js'; +import { setRole, verifyAndSetAffiliation } from "../../utils.js" +import { showOccupantModal } from '../../utils.js'; import { _converse, api } from "@converse/headless/core"; +export const tpl_footer = (el) => { + const model = el.model ?? el.message; + const jid = model.get('jid'); + const muc = model?.collection?.chatroom; + + if (!jid || !muc) { + return; + } + + const role = model.get('role') ?? 'none'; + const affiliation = model.get('affiliation'); + + const ownAffiliation = muc.getOwnAffiliation(); + + let handleBlock = (ev) => { + api.blockUser([jid]); + }; + let handleUnblock = (ev) => { + api.unblockUser([jid]); + }; + let handleKick = (ev) => { + setRole(muc, 'kick', jid, [], ['moderator']); + }; + let handleMute = (ev) => { + setRole(muc, 'mute', jid, [], ['moderator']); + }; + let handleVoice = (ev) => { + setRole(muc, 'voice', jid, [], ['moderator']); + }; + let handleOp = (ev) => { + setRole(muc, 'op', jid, ['admin', 'owner'], ['moderator']); + }; + let handleDeOp = (ev) => { + setRole(muc, 'deop', jid, ['admin', 'owner'], ['moderator']); + }; + let handleBan = (ev) => { + verifyAndSetAffiliation(muc, 'ban', jid, ["admin", "owner"]); + }; + let handleMember = (ev) => { + verifyAndSetAffiliation(muc, 'member', jid, ["admin", "owner"]); + }; + let handleAdmin = (ev) => { + verifyAndSetAffiliation(muc, 'admin', jid, ["admin", "owner"]); + }; + let handleOwner = async (ev) => { + const confirmed = await _converse.api.confirm("Are you sure you want to promote?", + ["Promoting a user to owner may be irreversible.", + "Only server administrators may demote an owner of a Multi User Chat."], + []).then((x) => x.length === 0); + if (confirmed) { + verifyAndSetAffiliation(muc, 'owner', jid, ["admin", "owner"]); + } else { + showOccupantModal(ev, model); + } + }; + + const blockButton = html`` + const unblockButton = html`` + const banButton = html`` + const kickButton = html`` + + const muteButton = html`` + const unmuteButton = html`` + const memberButton = (memberText) => html`` + const addToChatButton = memberButton("Add to Chat"); + const unbanButton = memberButton("Unban"); + const removeAdminButton = memberButton("Remove Admin Status"); + + const opButton = html`` + const deOpButton = html`` + const adminButton = html`` + const ownerButton = html`` + + // The following table stores a map from + // OwnAffiliation x { Target User's Role x Target User's Affiliation } -> Button + // Mapping to a button rather than a boolean provides us with a bit more + // flexibility in how we determine the names for certain actions. See + // the "Add to Chat" button vs the "Unban" button. They both represent + // a transformation to the target role "Member" but arise from different + // scenarios + + // The table is more or less copied verbatim from ejabberd's role permissions table + + // Who can ban (set affiliation to outcast)? + let canBan = ({ 'owner': [ 'none', 'member', 'admin' ].includes(affiliation) ? banButton : null, + 'admin': [ 'none', 'member' ].includes(affiliation) ? banButton : null, + 'member': [ 'none', 'member' ].includes(affiliation) ? banButton : null + && [ 'visitor', 'none', 'participant' ].includes(role) ? banButton : null, + })[ownAffiliation]; + + // Who can kick (set role to none)? + let canKick = ({ 'owner': [ 'none', 'member', 'admin' ].includes(affiliation) && role !== 'none' ? kickButton : null, + 'admin': [ 'none', 'member' ].includes(affiliation) && role !== 'none' ? kickButton : null, + 'member': [ 'none', 'member' ].includes(affiliation) && ![' none', 'moderator'].includes(role) ? kickButton : null, + })[ownAffiliation]; + + // Who can mute (set role to visitor)? + let canMute = ({ 'owner': [ 'none', 'member' ].includes(affiliation) && ![ 'visitor' ].includes(role) ? muteButton : null, + 'admin': [ 'none', 'member' ].includes(affiliation) && ![ 'visitor' ].includes(role) ? muteButton : null, + 'member': [ 'none', 'member' ].includes(affiliation) && ![ 'visitor', 'moderator'].includes(role) ? muteButton : null, + })[ownAffiliation]; + + // Who can unmute (set role to participant)? + let canUnmute = ({ 'owner': [ 'none', 'member' ].includes(affiliation) && [ 'visitor' ].includes(role) ? unmuteButton : null, + 'admin': [ 'none', 'member' ].includes(affiliation) && [ 'visitor' ].includes(role) ? unmuteButton : null, + 'member': [ 'none', 'member' ].includes(affiliation) && [ 'visitor' ].includes(role) ? unmuteButton : null, + })[ownAffiliation]; + + // Who can set affiliation to member? + let canMember = ({ 'owner': ({ 'admin': removeAdminButton, 'none': addToChatButton, 'outcast': unbanButton })[affiliation], + 'admin': ({ 'none': addToChatButton, 'outcast': unbanButton })[affiliation], + 'member': ({ 'none': addToChatButton })[affiliation], + })[ownAffiliation]; + + // Who can promote to moderator role? + let canOp = ({ 'owner': [ 'none', 'member' ].includes(affiliation) && [ 'none', 'participant' ].includes(role) ? opButton : null, + 'admin': [ 'none', 'member' ].includes(affiliation) && [ 'none', 'participant' ].includes(role) ? opButton : null, + })[ownAffiliation]; + // Who can remove moderator role? + let canDeOp = ({ 'owner': [ 'none', 'member' ].includes(affiliation) && role === 'moderator' ? deOpButton : null, + 'admin': [ 'none', 'member' ].includes(affiliation) && role === 'moderator' ? deOpButton : null, + })[ownAffiliation]; + + // Who can change affiliation to admin? + let canAdmin = ({ 'owner': [ 'none', 'member' ].includes(affiliation) ? adminButton : null, + })[ownAffiliation]; + + // Who can change affiliation to owner? + let canOwner = ({ 'owner': [ 'none', 'member', 'admin' ].includes(affiliation) ? ownerButton : null, + })[ownAffiliation]; + + + + let blocking_plug = _converse.pluggable.plugins['converse-blocking']?.enabled(_converse); + + let determineApplicable = function(command) { + switch (command) { + case('kick'): { return canKick; } + case('ban'): { return canBan; } + case('voice'): { return canUnmute; } + case('mute'): { return canMute; } + case('op'): { return canOp; } + case('deop'): { return canDeOp; } + case('member'): { return canMember; } + case('admin'): { return canAdmin; } + case('owner'): { return canOwner; } + case('block'): { return ( blocking_plug && jid && !api.blockedUsers().has(jid) ? blockButton : null ); } + case('unblock'): { return ( blocking_plug && jid && api.blockedUsers().has(jid) ? unblockButton : null ); } + default: { return null; } + } + }; + + const applicable_buttons = (muc?.getAllowedCommands() ?? []).map(determineApplicable).filter(x => x); + + return applicable_buttons ? html`
` : null; +} -export default (el) => { +export const tpl_occupant_modal = (el) => { const model = el.model ?? el.message; const jid = model?.get('jid'); const vcard = el.getVcard(); diff --git a/src/plugins/muc-views/utils.js b/src/plugins/muc-views/utils.js index 0b982648e3..424e67595b 100644 --- a/src/plugins/muc-views/utils.js +++ b/src/plugins/muc-views/utils.js @@ -133,7 +133,7 @@ export async function getAutoCompleteList () { return jids; } -function setRole (muc, command, args, required_affiliations = [], required_roles = []) { +export function setRole (muc, command, args, required_affiliations = [], required_roles = []) { const role = COMMAND_TO_ROLE[command]; if (!role) { throw Error(`ChatRoomView#setRole called with invalid command: ${command}`); @@ -156,7 +156,7 @@ function setRole (muc, command, args, required_affiliations = [], required_roles } -function verifyAndSetAffiliation (muc, command, args, required_affiliations) { +export function verifyAndSetAffiliation (muc, command, args, required_affiliations) { const affiliation = COMMAND_TO_AFFILIATION[command]; if (!affiliation) { throw Error(`verifyAffiliations called with invalid command: ${command}`); @@ -315,6 +315,12 @@ export function parseMessageForMUCCommands (data, handled) { } else if (command === 'voice' && allowed_commands.includes(command)) { setRole(model, command, args, [], ['moderator']); return true; + } else if ((command === 'ignore' || command === 'block') && allowed_commands.includes('block')) { + api.blockUser(args); + return true; + } else if ((command === 'unignore' || command === 'block') && allowed_commands.includes('unblock')) { + api.unblockUser(args); + return true; } else { return false; } diff --git a/src/plugins/notifications/utils.js b/src/plugins/notifications/utils.js index 4ea90c0353..902a6d23db 100644 --- a/src/plugins/notifications/utils.js +++ b/src/plugins/notifications/utils.js @@ -67,6 +67,13 @@ export async function shouldNotifyOfGroupMessage (attrs) { let is_mentioned = false; const nick = room.get('nick'); + if ( _converse.pluggable.plugins['converse-blocking']?.enabled(_converse) ) { + // Don't show notifications for blocked users + if (real_jid && api.blockedUsers()?.has(real_jid)) { + return false; + } + } + if (api.settings.get('notify_nicknames_without_references')) { is_mentioned = new RegExp(`\\b${nick}\\b`).test(attrs.body); } diff --git a/src/plugins/profile/index.js b/src/plugins/profile/index.js index 3b7557822e..347c4a538c 100644 --- a/src/plugins/profile/index.js +++ b/src/plugins/profile/index.js @@ -24,3 +24,37 @@ converse.plugins.add('converse-profile', { api.settings.extend({ 'show_client_info': true }); }, }); + +class BlockedUsersProfile extends CustomElement { + + async initialize () { + this.listenTo(_converse.blocked, 'change', () => this.requestUpdate() ); + } + + render () { + // TODO: Displaying the JID bare like this is probably wrong. It should probably be escaped + // sanitized, or canonicalized or something before display. The same goes for all such + // displays in this commit. + return ((el) => { return html`${jid}
+ +