소스 검색

Added more moderation UI complete with user blocking/ignoring

based-a-tron 2 년 전
부모
커밋
226bc49d18

+ 1 - 0
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";

+ 149 - 0
src/headless/plugins/blocking/api.js

@@ -0,0 +1,149 @@
+import log from '@converse/headless/log.js';
+import { _converse, api, converse } from "@converse/headless/core.js";
+import { setLastStanzaDate } from './utils.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');
+    }
+
+}

+ 39 - 0
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);
+    }
+});

+ 10 - 0
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);
+}
+
+

+ 4 - 1
src/headless/plugins/chat/model.js

@@ -1099,7 +1099,10 @@ 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);

+ 6 - 0
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']];
         }

+ 2 - 1
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 };

+ 12 - 1
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();

+ 160 - 2
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 { _converse, api } from "@converse/headless/core";
+import { setRole, verifyAndSetAffiliation } from "../../utils.js"
+import { showOccupantModal } from '../../utils.js';
+import { _converse, api, converse } 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 ownRole = muc.getOwnRole();
+    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`<button class='btn btn-primary' @click=${handleBlock}>Block</button>`
+    const unblockButton = html`<button class='btn btn-primary' @click=${handleUnblock}>Unblock</button>`
+    const banButton = html`<button class='btn btn-primary' @click=${handleBan}>Ban</button>`
+    const kickButton = html`<button class='btn btn-primary' @click=${handleKick}>Kick</button>`
+
+    const muteButton = html`<button class='btn btn-primary' @click=${handleMute}>Mute</button>`
+    const unmuteButton = html`<button class='btn btn-primary' @click=${handleVoice}>Unmute</button>`
+    const memberButton = (memberText) => html`<button class='btn btn-primary' @click=${handleMember}>${memberText}</button>`
+    const addToChatButton   = memberButton("Add to Chat");
+    const unbanButton       = memberButton("Unban");
+    const removeAdminButton = memberButton("Remove Admin Status");
+
+    const opButton = html`<button class='btn btn-primary' @click=${handleOp}>Make Moderator</button>`
+    const deOpButton = html`<button class='btn btn-primary' @click=${handleDeOp}>Remove Moderator Status</button>`
+    const adminButton = html`<button class='btn btn-primary' @click=${handleAdmin}>Make Admin</button>`
+    const ownerButton = html`<button class='btn btn-primary' @click=${handleOwner}>Make Owner</button>`
+
+    // The following table stores a map from Affiliation x Role -> 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`<div class="modal-footer">${applicable_buttons}</div>` : 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();

+ 6 - 0
src/plugins/muc-views/utils.js

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

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

+ 34 - 0
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`<ul>
+                ${Array.from(_converse
+                             .blocked
+                             .get('set'))
+                             .map(jid => html`<li><p>${jid}</p>
+                                                   <button @click=${el.unblock(jid)}>Unblock</button>
+                                              </li>
+                                             `
+                                 )
+                 }
+            </ul>`
+        })(this);
+    }
+
+    unblock (member) {
+        return ev => {
+            api.unblockUser([member]);
+        }
+    }
+}
+
+api.elements.define("converse-blockedusers-profile", BlockedUsersProfile);
+

+ 20 - 0
src/plugins/profile/templates/profile_modal.js

@@ -4,6 +4,12 @@ import { _converse } from  "@converse/headless/core";
 import { html } from "lit";
 
 
+const blockedusers_page = (el) => html`
+    <div class="tab-pane ${ el.tab === 'blockedusers' ? 'active' : ''}" id="blockedusers-tabpanel" role="tabpanel" aria-labelledby="blockedusers-tab">
+        <converse-blockedusers-profile></converse-blockedusers-profile>
+    </div>`;
+
+
 const tpl_omemo_page = (el) => html`
     <div class="tab-pane ${ el.tab === 'omemo' ? 'active' : ''}" id="omemo-tabpanel" role="tabpanel" aria-labelledby="omemo-tab">
         ${ el.tab === 'omemo' ? html`<converse-omemo-profile></converse-omemo-profile>` : '' }
@@ -51,6 +57,19 @@ export default (el) => {
         </li>`
     );
 
+    if (_converse.pluggable.plugins['converse-blocking']?.enabled(_converse)) {
+        navigation_tabs.push(html`<li role="presentation" class="nav-item">
+	    <a class="nav-link ${el.tab === "blockedusers" ? "active" : ""}"
+               id="blockedusers-tab"
+               href="#blockedusers-tabpanel"
+               aria-controls="blockedusers-tabpanel" role="tab"
+               @click=${ev => el.switchTab(ev)}
+               data-name="blockedusers"
+               data-toggle="tab">Blocked Users</a>
+            </li>`
+        );
+    }
+
     if (_converse.pluggable.plugins['converse-omemo']?.enabled(_converse)) {
         navigation_tabs.push(
             html`<li role="presentation" class="nav-item">
@@ -114,6 +133,7 @@ export default (el) => {
                 ${ el.tab === 'passwordreset' ? html`<converse-change-password-form></converse-change-password-form>` : '' }
             </div>
 
+            ${ _converse.pluggable.plugins['converse-blocking']?.enabled(_converse) ? blockedusers_page(el) : '' }
             ${ _converse.pluggable.plugins['converse-omemo']?.enabled(_converse) ? tpl_omemo_page(el) : '' }
         </div>
     </div>`;

+ 5 - 0
src/shared/chat/message.js

@@ -118,6 +118,11 @@ export default class Message extends CustomElement {
     }
 
     renderChatMessage () {
+        if ( _converse.pluggable.plugins['converse-blocking']?.enabled(_converse) && 
+             api.blockedUsers()?.has(this.getProps()?.properties?.jid)
+           ) {
+            return;
+        }
         return tpl_message(this, this.getProps());
     }
 

+ 20 - 0
src/shared/modals/templates/user-details.js

@@ -4,6 +4,21 @@ import { api } from "@converse/headless/core";
 import { html } from 'lit';
 import { modal_close_button } from "plugins/modal/templates/buttons.js";
 
+
+const block_button = (o) => {
+    const block_contact = "Block Contact";
+    return html`
+        <button @click=${(ev) => api.blockUser([o.jid])}>Block</button>
+    `;
+}
+
+const unblock_button = (o) => {
+    const block_contact = "Block Contact";
+    return html`
+        <button @click=${(ev) => api.unblockUser([o.jid])}>Unblock</button>
+    `;
+}
+
 const remove_button = (el) => {
     const i18n_remove_contact = __('Remove as contact');
     return html`
@@ -19,6 +34,10 @@ const remove_button = (el) => {
 }
 
 export const tpl_footer = (el) => {
+    const vcard = el.model?.vcard;
+    const vcard_json = vcard ? vcard.toJSON() : {};
+    const o = { ...el.model.toJSON(), ...vcard_json };
+
     const is_roster_contact = el.model.contact !== undefined;
     const i18n_refresh = __('Refresh');
     const allow_contact_removal = api.settings.get('allow_contact_removal');
@@ -33,6 +52,7 @@ export const tpl_footer = (el) => {
                 ></converse-icon>
                 ${i18n_refresh}</button>
             ${ (allow_contact_removal && is_roster_contact) ? remove_button(el) : '' }
+            ${ api.blockedUsers ? ((api.blockedUsers()?.has(o.jid)) ? unblock_button(o) : block_button(o)) : '' }
         </div>
     `;
 }