Browse Source

Add a button to block from the user details modal

JC Brand 5 months ago
parent
commit
de34759cfd

+ 7 - 11
src/headless/plugins/roster/contact.js

@@ -146,28 +146,24 @@ class RosterContact extends ColorAwareModel(Model) {
 
     /**
      * Remove this contact from the roster
-     * @param {boolean} unauthorize - Whether to also unauthorize the
+     * @param {boolean} [unauthorize] - Whether to also unauthorize the
      */
-    remove (unauthorize) {
+    async remove (unauthorize) {
         const subscription = this.get('subscription');
         if (subscription === 'none' && this.get('ask') !== 'subscribe') {
             this.destroy();
             return;
         }
 
-        if (unauthorize) {
-            if (subscription === 'from') {
-                this.unauthorize();
-            } else if (subscription === 'both') {
+        try {
+            if (unauthorize && ['from', 'both'].includes(subscription)) {
                 this.unauthorize();
             }
-        }
-
-        this.sendRosterRemoveStanza();
-        if (this.collection) {
+            await this.sendRosterRemoveStanza();
+        } finally {
             // The model might have already been removed as
             // result of a roster push.
-            this.destroy();
+            if (this.collection) this.destroy();
         }
     }
 

+ 2 - 2
src/headless/types/plugins/roster/contact.d.ts

@@ -118,9 +118,9 @@ declare class RosterContact extends RosterContact_base {
     authorize(message: string): this;
     /**
      * Remove this contact from the roster
-     * @param {boolean} unauthorize - Whether to also unauthorize the
+     * @param {boolean} [unauthorize] - Whether to also unauthorize the
      */
-    remove(unauthorize: boolean): void;
+    remove(unauthorize?: boolean): Promise<void>;
     /**
      * Instruct the XMPP server to remove this contact from our roster
      * @async

+ 38 - 42
src/plugins/rosterview/contactview.js

@@ -1,28 +1,27 @@
 import { Model } from '@converse/skeletor';
-import { _converse, converse, api, log } from "@converse/headless";
+import { _converse, converse, api, log } from '@converse/headless';
 import { CustomElement } from 'shared/components/element.js';
-import tplRequestingContact from "./templates/requesting_contact.js";
-import tplRosterItem from "./templates/roster_item.js";
-import tplUnsavedContact from "./templates/unsaved_contact.js";
+import tplRequestingContact from './templates/requesting_contact.js';
+import tplRosterItem from './templates/roster_item.js';
+import tplUnsavedContact from './templates/unsaved_contact.js';
 import { __ } from 'i18n';
+import { blockContact, removeContact } from './utils.js';
 
 const { Strophe } = converse.env;
 
-
 export default class RosterContact extends CustomElement {
-
-    static get properties () {
+    static get properties() {
         return {
-            model: { type: Object }
-        }
+            model: { type: Object },
+        };
     }
 
-    constructor () {
+    constructor() {
         super();
         this.model = null;
     }
 
-    initialize () {
+    initialize() {
         this.listenTo(this.model, 'change', () => this.requestUpdate());
         this.listenTo(this.model, 'highlight', () => this.requestUpdate());
         this.listenTo(this.model, 'vcard:add', () => this.requestUpdate());
@@ -30,7 +29,7 @@ export default class RosterContact extends CustomElement {
         this.listenTo(this.model, 'presenceChanged', () => this.requestUpdate());
     }
 
-    render () {
+    render() {
         if (this.model.get('requesting') === true) {
             return tplRequestingContact(this);
         } else if (this.model.get('subscription') === 'none') {
@@ -43,7 +42,7 @@ export default class RosterContact extends CustomElement {
     /**
      * @param {MouseEvent} ev
      */
-    openChat (ev) {
+    openChat(ev) {
         ev?.preventDefault?.();
         api.chats.open(this.model.get('jid'), this.model.attributes, true);
     }
@@ -53,43 +52,37 @@ export default class RosterContact extends CustomElement {
      */
     addContact(ev) {
         ev?.preventDefault?.();
-        api.modal.show('converse-add-contact-modal', {'model': new Model()}, ev);
+        api.modal.show('converse-add-contact-modal', { 'model': new Model() }, ev);
     }
 
     /**
      * @param {MouseEvent} ev
      */
-    async removeContact (ev) {
+    async removeContact(ev) {
         ev?.preventDefault?.();
-        if (!api.settings.get('allow_contact_removal')) { return; }
-
-        const result = await api.confirm(__("Are you sure you want to remove this contact?"));
-        if (!result)  return;
-
-        const chat = await api.chats.get(this.model.get('jid'));
-        chat?.close();
-        try {
-            // TODO: ask user whether they want to unauthorize the contact's
-            // presence request as well.
-            this.model.remove();
-        } catch (e) {
-            log.error(e);
-            api.alert('error', __('Error'),
-                [__('Sorry, there was an error while trying to remove %1$s as a contact.', this.model.getDisplayName())]
-            );
-        }
+        // TODO: ask user whether they want to unauthorize the contact's
+        // presence request as well.
+        await removeContact(this.model);
+    }
+
+    /**
+     * @param {MouseEvent} ev
+     */
+    async blockContact(ev) {
+        ev?.preventDefault?.();
+        await blockContact(this.model);
     }
 
     /**
      * @param {MouseEvent} ev
      */
-    async acceptRequest (ev) {
+    async acceptRequest(ev) {
         ev?.preventDefault?.();
 
         await _converse.state.roster.sendContactAddIQ({
             jid: this.model.get('jid'),
             name: this.model.getFullname(),
-            groups: []
+            groups: [],
         });
         this.model.authorize().subscribe();
     }
@@ -97,20 +90,23 @@ export default class RosterContact extends CustomElement {
     /**
      * @param {MouseEvent} ev
      */
-    async declineRequest (ev) {
-        if (ev && ev.preventDefault) { ev.preventDefault(); }
-
+    async declineRequest(ev) {
+        ev?.preventDefault?.();
         const domain = _converse.session.get('domain');
         const blocking_supported = await api.disco.supports(Strophe.NS.BLOCKING, domain);
 
         const result = await api.confirm(
             __('Decline contact request'),
             [__('Are you sure you want to decline this contact request?')],
-            blocking_supported ? [{
-                label: __('Block this user from sending you further messages'),
-                name: 'block',
-                type: 'checkbox'
-            }] : []
+            blocking_supported
+                ? [
+                      {
+                          label: __('Block this user from sending you further messages'),
+                          name: 'block',
+                          type: 'checkbox',
+                      },
+                  ]
+                : []
         );
 
         if (result) {

+ 110 - 55
src/plugins/rosterview/utils.js

@@ -4,32 +4,84 @@
  * @typedef {import('@converse/headless').RosterContacts} RosterContacts
  */
 import { __ } from 'i18n';
-import { _converse, api, converse, log, constants, u, XMPPStatus } from "@converse/headless";
+import { _converse, api, converse, log, constants, u, XMPPStatus } from '@converse/headless';
 
 const { Strophe } = converse.env;
 const { STATUS_WEIGHTS } = constants;
 
 /**
  * @param {RosterContact} contact
+ * @param {boolean} [unsubscribe]
+ * @returns {Promise<boolean>}
  */
-export async function removeContact (contact) {
+export async function removeContact(contact, unsubscribe = false) {
+    if (!api.settings.get('allow_contact_removal')) return;
+
+    const result = await api.confirm(__('Are you sure you want to remove this contact?'));
+    if (!result) return false;
+
+    const chat = await api.chats.get(contact.get('jid'));
+    chat?.close();
+    try {
+        contact.remove(unsubscribe);
+    } catch (e) {
+        log.error(e);
+        api.alert('error', __('Error'), [
+            __('Sorry, an error occurred while trying to remove %1$s as a contact', contact.getDisplayName()),
+        ]);
+    }
+    return true;
+}
+
+/**
+ * @param {RosterContact} contact
+ * @returns {Promise<boolean>}
+ */
+export async function blockContact(contact) {
+    const domain = _converse.session.get('domain');
+    if (!(await api.disco.supports(Strophe.NS.BLOCKING, domain))) return false;
+
+    const i18n_confirm = __('Do you want to block this contact, so they cannot send you messages?');
+    if (!(await api.confirm(i18n_confirm))) return false;
+
+    (await api.chats.get(contact.get('jid')))?.close();
+
+    try {
+        contact.remove(true);
+        await api.blocklist.add(contact.get('jid'));
+    } catch (e) {
+        log.error(e);
+        api.alert('error', __('Error'), [
+            __('Sorry, an error occurred while trying to block %1$s', contact.getDisplayName()),
+        ]);
+    }
+}
+
+/**
+ * @param {RosterContact} contact
+ * @returns {Promise<boolean>}
+ */
+export async function unblockContact(contact) {
+    const domain = _converse.session.get('domain');
+    if (!(await api.disco.supports(Strophe.NS.BLOCKING, domain))) return false;
+
+    const i18n_confirm = __('Do you want to unblock this contact, so they can send you messages?');
+    if (!(await api.confirm(i18n_confirm))) return false;
+
     try {
-        await contact.sendRosterRemoveStanza();
+        await api.blocklist.remove(contact.get('jid'));
     } catch (e) {
         log.error(e);
         api.alert('error', __('Error'), [
-            __('Sorry, there was an error while trying to remove %1$s as a contact.',
-            contact.getDisplayName())
+            __('Sorry, an error occurred while trying to unblock %1$s', contact.getDisplayName()),
         ]);
-    } finally {
-        contact.destroy();
     }
 }
 
 /**
  * @param {string} jid
  */
-export function highlightRosterItem (jid) {
+export function highlightRosterItem(jid) {
     _converse.state.roster?.get(jid)?.trigger('highlight');
 }
 
@@ -37,12 +89,15 @@ export function highlightRosterItem (jid) {
  * @param {Event} ev
  * @param {string} name
  */
-export function toggleGroup (ev, name) {
+export function toggleGroup(ev, name) {
     ev?.preventDefault?.();
     const { roster } = _converse.state;
     const collapsed = roster.state.get('collapsed_groups');
     if (collapsed.includes(name)) {
-        roster.state.save('collapsed_groups', collapsed.filter(n => n !== name));
+        roster.state.save(
+            'collapsed_groups',
+            collapsed.filter((n) => n !== name)
+        );
     } else {
         roster.state.save('collapsed_groups', [...collapsed, name]);
     }
@@ -71,12 +126,10 @@ function getFilterCriteria(contact) {
  * @param {string} groupname
  * @returns {boolean}
  */
-export function isContactFiltered (contact, groupname) {
+export function isContactFiltered(contact, groupname) {
     const filter = _converse.state.roster_filter;
     const type = filter.get('type');
-    const q = (type === 'state') ?
-        filter.get('state').toLowerCase() :
-        filter.get('text').toLowerCase();
+    const q = type === 'state' ? filter.get('state').toLowerCase() : filter.get('text').toLowerCase();
 
     if (!q) return false;
 
@@ -90,11 +143,11 @@ export function isContactFiltered (contact, groupname) {
         } else if (q === 'unread_messages') {
             return contact.get('num_unread') === 0;
         } else if (q === 'online') {
-            return ["offline", "unavailable", "dnd", "away", "xa"].includes(contact.getStatus());
+            return ['offline', 'unavailable', 'dnd', 'away', 'xa'].includes(contact.getStatus());
         } else {
             return !contact.getStatus().includes(q);
         }
-    } else if (type === 'items')  {
+    } else if (type === 'items') {
         return !getFilterCriteria(contact).includes(q);
     }
 }
@@ -105,15 +158,17 @@ export function isContactFiltered (contact, groupname) {
  * @param {Model} model
  * @returns {boolean}
  */
-export function shouldShowContact (contact, groupname, model) {
+export function shouldShowContact(contact, groupname, model) {
     if (!model.get('filter_visible')) return true;
 
     const chat_status = contact.getStatus();
     if (api.settings.get('hide_offline_users') && chat_status === 'offline') {
         // If pending or requesting, show
-        if ((contact.get('ask') === 'subscribe') ||
-                (contact.get('subscription') === 'from') ||
-                (contact.get('requesting') === true)) {
+        if (
+            contact.get('ask') === 'subscribe' ||
+            contact.get('subscription') === 'from' ||
+            contact.get('requesting') === true
+        ) {
             return !isContactFiltered(contact, groupname);
         }
         return false;
@@ -125,7 +180,7 @@ export function shouldShowContact (contact, groupname, model) {
  * @param {string} group
  * @param {Model} model
  */
-export function shouldShowGroup (group, model) {
+export function shouldShowGroup(group, model) {
     if (!model.get('filter_visible')) return true;
 
     const filter = _converse.state.roster_filter;
@@ -148,21 +203,21 @@ export function shouldShowGroup (group, model) {
  * @param {RosterContact} contact
  * @returns {import('./types').ContactsMap}
  */
-export function populateContactsMap (contacts_map, contact) {
+export function populateContactsMap(contacts_map, contact) {
     const { labels } = _converse;
 
-    const contact_groups = /** @type {string[]} */(u.unique(contact.get('groups') ?? []));
+    const contact_groups = /** @type {string[]} */ (u.unique(contact.get('groups') ?? []));
 
     if (contact.get('requesting')) {
-        contact_groups.push(/** @type {string} */(labels.HEADER_REQUESTING_CONTACTS));
+        contact_groups.push(/** @type {string} */ (labels.HEADER_REQUESTING_CONTACTS));
     } else if (contact.get('ask') === 'subscribe') {
-        contact_groups.push(/** @type {string} */(labels.HEADER_PENDING_CONTACTS));
+        contact_groups.push(/** @type {string} */ (labels.HEADER_PENDING_CONTACTS));
     } else if (contact.get('subscription') === 'none') {
-        contact_groups.push(/** @type {string} */(labels.HEADER_UNSAVED_CONTACTS));
+        contact_groups.push(/** @type {string} */ (labels.HEADER_UNSAVED_CONTACTS));
     } else if (!api.settings.get('roster_groups')) {
-        contact_groups.push(/** @type {string} */(labels.HEADER_CURRENT_CONTACTS));
+        contact_groups.push(/** @type {string} */ (labels.HEADER_CURRENT_CONTACTS));
     } else if (!contact_groups.length) {
-        contact_groups.push(/** @type {string} */(labels.HEADER_UNGROUPED));
+        contact_groups.push(/** @type {string} */ (labels.HEADER_UNGROUPED));
     }
 
     for (const name of contact_groups) {
@@ -173,7 +228,7 @@ export function populateContactsMap (contacts_map, contact) {
     }
 
     if (contact.get('num_unread')) {
-        const name = /** @type {string} */(labels.HEADER_UNREAD);
+        const name = /** @type {string} */ (labels.HEADER_UNREAD);
         contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
     }
     return contacts_map;
@@ -184,14 +239,14 @@ export function populateContactsMap (contacts_map, contact) {
  * @param {RosterContact|XMPPStatus} contact2
  * @returns {(-1|0|1)}
  */
-export function contactsComparator (contact1, contact2) {
+export function contactsComparator(contact1, contact2) {
     const status1 = contact1.getStatus();
     const status2 = contact2.getStatus();
     if (STATUS_WEIGHTS[status1] === STATUS_WEIGHTS[status2]) {
-        const name1 = (contact1.getDisplayName()).toLowerCase();
-        const name2 = (contact2.getDisplayName()).toLowerCase();
-        return name1 < name2 ? -1 : (name1 > name2? 1 : 0);
-    } else  {
+        const name1 = contact1.getDisplayName().toLowerCase();
+        const name2 = contact2.getDisplayName().toLowerCase();
+        return name1 < name2 ? -1 : name1 > name2 ? 1 : 0;
+    } else {
         return STATUS_WEIGHTS[status1] < STATUS_WEIGHTS[status2] ? -1 : 1;
     }
 }
@@ -200,7 +255,7 @@ export function contactsComparator (contact1, contact2) {
  * @param {string} a
  * @param {string} b
  */
-export function groupsComparator (a, b) {
+export function groupsComparator(a, b) {
     const HEADER_WEIGHTS = {};
     const {
         HEADER_UNREAD,
@@ -212,47 +267,47 @@ export function groupsComparator (a, b) {
 
     HEADER_WEIGHTS[HEADER_UNREAD] = 0;
     HEADER_WEIGHTS[HEADER_REQUESTING_CONTACTS] = 1;
-    HEADER_WEIGHTS[HEADER_CURRENT_CONTACTS]    = 2;
-    HEADER_WEIGHTS[HEADER_UNGROUPED]           = 3;
-    HEADER_WEIGHTS[HEADER_PENDING_CONTACTS]    = 4;
+    HEADER_WEIGHTS[HEADER_CURRENT_CONTACTS] = 2;
+    HEADER_WEIGHTS[HEADER_UNGROUPED] = 3;
+    HEADER_WEIGHTS[HEADER_PENDING_CONTACTS] = 4;
 
-    const WEIGHTS =  HEADER_WEIGHTS;
+    const WEIGHTS = HEADER_WEIGHTS;
     const special_groups = Object.keys(HEADER_WEIGHTS);
     const a_is_special = special_groups.includes(a);
     const b_is_special = special_groups.includes(b);
-    if (!a_is_special && !b_is_special ) {
-        return a.toLowerCase() < b.toLowerCase() ? -1 : (a.toLowerCase() > b.toLowerCase() ? 1 : 0);
+    if (!a_is_special && !b_is_special) {
+        return a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0;
     } else if (a_is_special && b_is_special) {
-        return WEIGHTS[a] < WEIGHTS[b] ? -1 : (WEIGHTS[a] > WEIGHTS[b] ? 1 : 0);
+        return WEIGHTS[a] < WEIGHTS[b] ? -1 : WEIGHTS[a] > WEIGHTS[b] ? 1 : 0;
     } else if (!a_is_special && b_is_special) {
         const a_header = HEADER_CURRENT_CONTACTS;
-        return WEIGHTS[a_header] < WEIGHTS[b] ? -1 : (WEIGHTS[a_header] > WEIGHTS[b] ? 1 : 0);
+        return WEIGHTS[a_header] < WEIGHTS[b] ? -1 : WEIGHTS[a_header] > WEIGHTS[b] ? 1 : 0;
     } else if (a_is_special && !b_is_special) {
         const b_header = HEADER_CURRENT_CONTACTS;
-        return WEIGHTS[a] < WEIGHTS[b_header] ? -1 : (WEIGHTS[a] > WEIGHTS[b_header] ? 1 : 0);
+        return WEIGHTS[a] < WEIGHTS[b_header] ? -1 : WEIGHTS[a] > WEIGHTS[b_header] ? 1 : 0;
     }
 }
 
-export function getGroupsAutoCompleteList () {
-    const roster = /** @type {RosterContacts} */(_converse.state.roster);
+export function getGroupsAutoCompleteList() {
+    const roster = /** @type {RosterContacts} */ (_converse.state.roster);
     const groups = roster.reduce((groups, contact) => groups.concat(contact.get('groups')), []);
-    return [...new Set(groups.filter(i => i))];
+    return [...new Set(groups.filter((i) => i))];
 }
 
-export function getJIDsAutoCompleteList () {
-    const roster = /** @type {RosterContacts} */(_converse.state.roster);
-    return [...new Set(roster.map(item => Strophe.getDomainFromJid(item.get('jid'))))];
+export function getJIDsAutoCompleteList() {
+    const roster = /** @type {RosterContacts} */ (_converse.state.roster);
+    return [...new Set(roster.map((item) => Strophe.getDomainFromJid(item.get('jid'))))];
 }
 
 /**
  * @param {string} query
  */
-export async function getNamesAutoCompleteList (query) {
+export async function getNamesAutoCompleteList(query) {
     const options = {
-        'mode': /** @type {RequestMode} */('cors'),
+        'mode': /** @type {RequestMode} */ ('cors'),
         'headers': {
-            'Accept': 'text/json'
-        }
+            'Accept': 'text/json',
+        },
     };
     const url = `${api.settings.get('xhr_user_search_url')}q=${encodeURIComponent(query)}`;
     let response;
@@ -269,5 +324,5 @@ export async function getNamesAutoCompleteList (query) {
         log.error(`Invalid JSON returned"`);
         return [];
     }
-    return json.map(i => ({'label': i.fullname || i.jid, 'value': i.jid}));
+    return json.map((i) => ({ 'label': i.fullname || i.jid, 'value': i.jid }));
 }

+ 80 - 39
src/shared/modals/templates/user-details.js

@@ -1,21 +1,20 @@
-import avatar from 'shared/avatar/templates/avatar.js';
-import { __ } from 'i18n';
-import { api } from "@converse/headless";
 import { html } from 'lit';
-import { modal_close_button } from "plugins/modal/templates/buttons.js";
+import { until } from 'lit/directives/until.js';
+import { api, converse, _converse } from '@converse/headless';
+import { __ } from 'i18n';
+import avatar from 'shared/avatar/templates/avatar.js';
+
+const { Strophe } = converse.env;
 
 /**
  * @param {import('../user-details').default} el
  */
-const remove_button = (el) => {
-    const i18n_remove_contact = __('Remove as contact');
+function tplUnblockButton(el) {
+    const i18n_block = __('Remove from blocklist');
     return html`
-        <button type="button" @click="${ev => el.removeContact(ev)}" class="btn btn-danger remove-contact">
-            <converse-icon
-                class="fas fa-trash-alt"
-                color="var(--foreground-color)"
-                size="1em"
-            ></converse-icon>&nbsp;${i18n_remove_contact}
+        <button type="button" @click="${(ev) => el.unblockContact(ev)}" class="btn btn-danger">
+            <converse-icon class="fas fa-times" color="var(--foreground-color)" size="1em"></converse-icon
+            >&nbsp;${i18n_block}
         </button>
     `;
 }
@@ -23,56 +22,98 @@ const remove_button = (el) => {
 /**
  * @param {import('../user-details').default} el
  */
-export const tplFooter = (el) => {
-    const is_roster_contact = el.model.contact !== undefined;
-    const i18n_refresh = __('Refresh');
-    const allow_contact_removal = api.settings.get('allow_contact_removal');
+function tplBlockButton(el) {
+    const i18n_block = __('Add to blocklist');
     return html`
-        <div class="modal-footer">
-            ${ modal_close_button }
-            <button type="button" class="btn btn-info refresh-contact" @click=${ev => el.refreshContact(ev)}>
-                <converse-icon
-                    class="fa fa-refresh"
-                    color="var(--foreground-color)"
-                    size="1em"
-                ></converse-icon>&nbsp;${i18n_refresh}</button>
-            ${ (allow_contact_removal && is_roster_contact) ? remove_button(el) : '' }
-        </div>
+        <button type="button" @click="${(ev) => el.blockContact(ev)}" class="btn btn-danger">
+            <converse-icon class="fas fa-times" color="var(--foreground-color)" size="1em"></converse-icon
+            >&nbsp;${i18n_block}
+        </button>
     `;
 }
 
+/**
+ * @param {import('../user-details').default} el
+ */
+function tplRemoveButton(el) {
+    const i18n_remove_contact = __('Remove as contact');
+    return html`
+        <button type="button" @click="${(ev) => el.removeContact(ev)}" class="btn btn-danger remove-contact">
+            <converse-icon class="fas fa-trash-alt" color="var(--foreground-color)" size="1em"></converse-icon
+            >&nbsp;${i18n_remove_contact}
+        </button>
+    `;
+}
 
 /**
  * @param {import('../user-details').default} el
  */
-export const tplUserDetailsModal = (el) => {
+export function tplUserDetailsModal(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 allow_contact_removal = api.settings.get('allow_contact_removal');
+
+    const domain = _converse.session.get('domain');
+    const blocking_supported = api.disco.supports(Strophe.NS.BLOCKING, domain).then(
+        /** @param {boolean} supported */
+        async (supported) => {
+            const blocklist = await api.blocklist.get();
+            if (supported) {
+                if (blocklist.get(el.model.get('jid'))) {
+                    tplUnblockButton(el);
+                } else {
+                    tplBlockButton(el);
+                }
+            }
+        }
+    );
+
+    const i18n_refresh = __('Refetch data');
     const i18n_address = __('XMPP Address');
     const i18n_email = __('Email');
     const i18n_full_name = __('Full Name');
     const i18n_nickname = __('Nickname');
-    const i18n_profile = __('The User\'s Profile Image');
+    const i18n_profile = __("The User's Profile Image");
     const i18n_role = __('Role');
     const i18n_url = __('URL');
+
     const avatar_data = {
-        'alt_text': i18n_profile,
-        'extra_classes': 'mb-3',
-        'height': '120',
-        'width': '120'
-    }
+        alt_text: i18n_profile,
+        extra_classes: 'mb-3',
+        height: '120',
+        width: '120',
+    };
 
     return html`
         <div class="modal-body">
-            ${ o.image ? html`<div class="mb-4">${avatar(Object.assign(o, avatar_data))}</div>` : '' }
-            ${ o.fullname ? html`<p><label>${i18n_full_name}:</label> ${o.fullname}</p>` : '' }
+            ${o.image ? html`<div class="mb-4">${avatar(Object.assign(o, avatar_data))}</div>` : ''}
+            ${o.fullname ? html`<p><label>${i18n_full_name}:</label> ${o.fullname}</p>` : ''}
             <p><label>${i18n_address}:</label> <a href="xmpp:${o.jid}">${o.jid}</a></p>
-            ${ o.nickname ? html`<p><label>${i18n_nickname}:</label> ${o.nickname}</p>` : '' }
-            ${ o.url ? html`<p><label>${i18n_url}:</label> <a target="_blank" rel="noopener" href="${o.url}">${o.url}</a></p>` : '' }
-            ${ o.email ? html`<p><label>${i18n_email}:</label> <a href="mailto:${o.email}">${o.email}</a></p>` : '' }
-            ${ o.role ? html`<p><label>${i18n_role}:</label> ${o.role}</p>` : '' }
+            ${o.nickname ? html`<p><label>${i18n_nickname}:</label> ${o.nickname}</p>` : ''}
+            ${o.url
+                ? html`<p>
+                      <label>${i18n_url}:</label> <a target="_blank" rel="noopener" href="${o.url}">${o.url}</a>
+                  </p>`
+                : ''}
+            ${o.email ? html`<p><label>${i18n_email}:</label> <a href="mailto:${o.email}">${o.email}</a></p>` : ''}
+            ${o.role ? html`<p><label>${i18n_role}:</label> ${o.role}</p>` : ''}
+
+            <hr />
+            <div>
+                <button type="button" class="btn btn-info refresh-contact" @click=${(ev) => el.refreshContact(ev)}>
+                    <converse-icon class="fa fa-refresh" color="var(--foreground-color)" size="1em"></converse-icon
+                    >&nbsp;${i18n_refresh}
+                </button>
+
+                ${allow_contact_removal && is_roster_contact ? tplRemoveButton(el) : ''}
+                ${until(
+                    blocking_supported.then(() => tplBlockButton(el)),
+                    ''
+                )}
+            </div>
 
             <converse-omemo-fingerprints jid=${o.jid}></converse-omemo-fingerprints>
         </div>

+ 26 - 16
src/shared/modals/user-details.js

@@ -1,11 +1,8 @@
-/**
- * @typedef {import('@converse/headless').ChatBox} ChatBox
- */
 import { api, converse, log } from "@converse/headless";
-import { removeContact } from 'plugins/rosterview/utils.js';
+import { blockContact, removeContact, unblockContact } from 'plugins/rosterview/utils.js';
 import BaseModal from "plugins/modal/modal.js";
 import { __ } from 'i18n';
-import { tplUserDetailsModal, tplFooter } from "./templates/user-details.js";
+import { tplUserDetailsModal } from "./templates/user-details.js";
 
 const u = converse.env.utils;
 
@@ -20,7 +17,7 @@ export default class UserDetailsModal extends BaseModal {
         /**
          * Triggered once the UserDetailsModal has been initialized
          * @event _converse#userDetailsModalInitialized
-         * @type {ChatBox}
+         * @type {import('@converse/headless').ChatBox}
          * @example _converse.api.listen.on('userDetailsModalInitialized', (chatbox) => { ... });
          */
         api.trigger('userDetailsModalInitialized', this.model);
@@ -30,10 +27,6 @@ export default class UserDetailsModal extends BaseModal {
         return tplUserDetailsModal(this);
     }
 
-    renderModalFooter () {
-        return tplFooter(this);
-    }
-
     getModalTitle () {
         return this.model.getDisplayName();
     }
@@ -62,14 +55,31 @@ export default class UserDetailsModal extends BaseModal {
         u.removeClass('fa-spin', refresh_icon);
     }
 
+    /**
+     * @param {MouseEvent} ev
+     */
     async removeContact (ev) {
         ev?.preventDefault?.();
-        if (!api.settings.get('allow_contact_removal')) { return; }
-        const result = await api.confirm(__("Are you sure you want to remove this contact?"));
-        if (result) {
-            setTimeout(() => removeContact(this.model.contact), 1);
-            this.modal.hide();
-        }
+        setTimeout(() => removeContact(this.model.contact), 1);
+        this.modal.hide();
+    }
+
+    /**
+     * @param {MouseEvent} ev
+     */
+    async blockContact(ev) {
+        ev?.preventDefault?.();
+        setTimeout(() => blockContact(this.model.contact), 1);
+        this.modal.hide();
+    }
+
+    /**
+     * @param {MouseEvent} ev
+     */
+    async unblockContact(ev) {
+        ev?.preventDefault?.();
+        setTimeout(() => unblockContact(this.model.contact), 1);
+        this.modal.hide();
     }
 }
 

+ 1 - 0
src/shared/texture/utils.js

@@ -37,6 +37,7 @@ export async function getHeaders(url) {
     }
 }
 
+
 /**
  * We don't render more than two line-breaks, replace extra line-breaks with
  * the zero-width whitespace character

+ 4 - 0
src/types/plugins/rosterview/contactview.d.ts

@@ -19,6 +19,10 @@ export default class RosterContact extends CustomElement {
      * @param {MouseEvent} ev
      */
     removeContact(ev: MouseEvent): Promise<void>;
+    /**
+     * @param {MouseEvent} ev
+     */
+    blockContact(ev: MouseEvent): Promise<void>;
     /**
      * @param {MouseEvent} ev
      */

+ 14 - 2
src/types/plugins/rosterview/utils.d.ts

@@ -1,7 +1,19 @@
 /**
  * @param {RosterContact} contact
+ * @param {boolean} [unsubscribe]
+ * @returns {Promise<boolean>}
  */
-export function removeContact(contact: RosterContact): Promise<void>;
+export function removeContact(contact: RosterContact, unsubscribe?: boolean): Promise<boolean>;
+/**
+ * @param {RosterContact} contact
+ * @returns {Promise<boolean>}
+ */
+export function blockContact(contact: RosterContact): Promise<boolean>;
+/**
+ * @param {RosterContact} contact
+ * @returns {Promise<boolean>}
+ */
+export function unblockContact(contact: RosterContact): Promise<boolean>;
 /**
  * @param {string} jid
  */
@@ -59,5 +71,5 @@ export function getNamesAutoCompleteList(query: string): Promise<{
 export type Model = import("@converse/skeletor").Model;
 export type RosterContact = import("@converse/headless").RosterContact;
 export type RosterContacts = import("@converse/headless").RosterContacts;
-import { XMPPStatus } from "@converse/headless";
+import { XMPPStatus } from '@converse/headless';
 //# sourceMappingURL=utils.d.ts.map

+ 3 - 1
src/types/shared/modals/templates/user-details.d.ts

@@ -1,3 +1,5 @@
-export function tplFooter(el: import("../user-details").default): import("lit").TemplateResult<1>;
+/**
+ * @param {import('../user-details').default} el
+ */
 export function tplUserDetailsModal(el: import("../user-details").default): import("lit").TemplateResult<1>;
 //# sourceMappingURL=user-details.d.ts.map

+ 12 - 3
src/types/shared/modals/user-details.d.ts

@@ -1,11 +1,20 @@
 export default class UserDetailsModal extends BaseModal {
     renderModal(): import("lit").TemplateResult<1>;
-    renderModalFooter(): import("lit").TemplateResult<1>;
     getModalTitle(): any;
     registerContactEventHandlers(): void;
     refreshContact(ev: any): Promise<void>;
-    removeContact(ev: any): Promise<void>;
+    /**
+     * @param {MouseEvent} ev
+     */
+    removeContact(ev: MouseEvent): Promise<void>;
+    /**
+     * @param {MouseEvent} ev
+     */
+    blockContact(ev: MouseEvent): Promise<void>;
+    /**
+     * @param {MouseEvent} ev
+     */
+    unblockContact(ev: MouseEvent): Promise<void>;
 }
-export type ChatBox = import("@converse/headless").ChatBox;
 import BaseModal from "plugins/modal/modal.js";
 //# sourceMappingURL=user-details.d.ts.map