Browse Source

Add a modal to show the block list

JC Brand 1 tháng trước cách đây
mục cha
commit
b7a12339c3

+ 1 - 0
karma.conf.js

@@ -149,6 +149,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/rosterview/tests/new-chat-modal.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/presence.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/protocol.js", type: 'module' },
+      { pattern: "src/plugins/rosterview/tests/blocklist.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/requesting_contacts.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/roster.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/unsaved-contacts.js", type: 'module' },

+ 1 - 0
src/headless/plugins/blocklist/api.js

@@ -41,6 +41,7 @@ const blocklist = {
         const blocklist = await waitUntil('blocklistInitialized');
         const jids = Array.isArray(jid) ? jid : [jid];
         if (send_stanza) await sendUnblockStanza(jids);
+        jids.forEach((jid) => blocklist.get(jid)?.destroy());
         blocklist.remove(jids);
         return blocklist;
     },

+ 1 - 1
src/plugins/profile/utils.js

@@ -1,5 +1,5 @@
 import { __ } from 'i18n';
-import { _converse, api } from '@converse/headless';
+import { _converse } from '@converse/headless';
 
 /**
  * @param {string} stat

+ 6 - 4
src/plugins/rosterview/index.js

@@ -9,6 +9,7 @@ import '../modal';
 import AddContactModal from './modals/add-contact.js';
 import AcceptContactRequestModal from './modals/accept-contact-request.js';
 import NewChatModal from './modals/new-chat.js';
+import BlockListModal from './modals/blocklist.js';
 import RosterView from './rosterview.js';
 
 import 'shared/styles/status.scss';
@@ -27,12 +28,13 @@ converse.plugins.add('converse-rosterview', {
         api.promises.add('rosterViewInitialized');
 
         const exports = {
-            RosterFilter,
-            RosterView,
-            RosterContactView,
-            AddContactModal,
             AcceptContactRequestModal,
+            AddContactModal,
+            BlockListModal,
             NewChatModal,
+            RosterContactView,
+            RosterFilter,
+            RosterView,
         };
         Object.assign(_converse, exports); // DEPRECATED
         Object.assign(_converse.exports, exports);

+ 79 - 0
src/plugins/rosterview/modals/blocklist.js

@@ -0,0 +1,79 @@
+import { html } from 'lit';
+import { _converse, api } from '@converse/headless';
+import BaseModal from 'plugins/modal/modal.js';
+import tplBlocklist from './templates/blocklist.js';
+import { __ } from 'i18n';
+
+export default class BlockListModal extends BaseModal {
+    static get properties() {
+        return {
+            ...super.properties,
+            filter_text: { type: String },
+        };
+    }
+
+    constructor() {
+        super();
+        this.filter_text = '';
+    }
+
+    async initialize() {
+        super.initialize();
+        this.blocklist = await api.blocklist.get();
+        this.listenTo(this.blocklist, 'add', () => this.requestUpdate());
+        this.listenTo(this.blocklist, 'remove', () => this.requestUpdate());
+        this.listenTo(this.blocklist, 'change', () => this.requestUpdate());
+        this.addEventListener(
+            'shown.bs.modal',
+            () => {
+                /** @type {HTMLInputElement} */ (this.querySelector('input[name="blocklist_filter"]'))?.focus();
+            },
+            false
+        );
+        this.requestUpdate();
+    }
+
+    renderModal() {
+        if (this.blocklist) {
+            return tplBlocklist(this);
+        } else {
+            return html`<converse-spinner></converse-spinner>`;
+        }
+    }
+
+    getModalTitle() {
+        return __('Blocklist');
+    }
+
+    /**
+     * @param {MouseEvent} ev
+     */
+    async unblockUsers(ev) {
+        ev.preventDefault();
+        const form = /** @type {HTMLFormElement} */ (ev.target);
+        const data = new FormData(form);
+        const jids = [...data.entries()].filter((e) => e[1] === 'on').map((e) => e[0]);
+        await api.blocklist.remove(jids);
+        const body =
+            jids.length > 1
+                ? __('Successfully unblocked %1$s XMPP addresses', jids.length)
+                : __('Successfully unblocked one XMPP address');
+        api.toast.show('blocked', {
+            type: 'success',
+            body,
+        });
+    }
+
+    /**
+     * @param {MouseEvent} ev
+     */
+    toggleSelectAll(ev) {
+        const value = /** @type {HTMLInputElement} */ (ev.target).checked;
+        const checkboxes = /** @type {NodeListOf<HTMLInputElement>} */ (
+            this.querySelectorAll('input[type="checkbox"]')
+        );
+        checkboxes.forEach((cb) => (cb.checked = value));
+    }
+}
+
+api.elements.define('converse-blocklist-modal', BlockListModal);

+ 105 - 0
src/plugins/rosterview/modals/templates/blocklist.js

@@ -0,0 +1,105 @@
+import { __ } from 'i18n';
+import { html } from 'lit';
+import { repeat } from 'lit/directives/repeat.js';
+
+/**
+ * @param {import('../blocklist.js').default} el
+ */
+export default (el) => {
+    if (el.blocklist.length) {
+        const filtered_blocklist = el.filter_text
+            ? el.blocklist.filter((b) => b.get('jid').toLowerCase().includes(el.filter_text.toLowerCase()))
+            : el.blocklist;
+
+        return html`
+            <form @submit="${(ev) => el.unblockUsers(ev)}">
+                <div class="d-flex justify-content-between">
+                    ${el.blocklist.length > 5
+                        ? html`<p style="line-height: 3em">
+                              ${__('%1$s blocked users shown', filtered_blocklist.length)}
+                          </p>`
+                        : ''}
+                    ${filtered_blocklist.length
+                        ? html`<div class="text-end">
+                              <button type="submit" class="btn btn-danger mt-2 mb-2">
+                                  ${__('Unblock selected users')}
+                              </button>
+                          </div>`
+                        : ''}
+                </div>
+                ${el.blocklist.length > 5
+                    ? html`
+                          <div class="mb-3  ${el.filter_text ? 'input-group' : ''}">
+                              <input
+                                  autofocus
+                                  name="blocklist_filter"
+                                  type="text"
+                                  class="form-control"
+                                  value="${el.filter_text}"
+                                  placeholder="${__('Filter blocked users')}"
+                                  @input="${(ev) => (el.filter_text = ev.target.value)}"
+                              />
+                              <button
+                                  type="button"
+                                  class="btn btn-outline-secondary ${!el.filter_text ? 'hidden' : ''}"
+                                  @click=${() => {
+                                      el.filter_text = '';
+                                      const input = /** @type {HTMLInputElement} */ (
+                                          el.querySelector('input[name="blocklist_filter"]')
+                                      );
+                                      input.value = '';
+                                  }}
+                              >
+                                  <converse-icon size="1em" class="fa fa-times"></converse-icon>
+                                  ${__('clear')}
+                              </button>
+                          </div>
+                          <div class="form-check mb-1">
+                              <input
+                                  class="form-check-input"
+                                  type="checkbox"
+                                  id="select-all"
+                                  @change="${(ev) => el.toggleSelectAll(ev)}"
+                              />
+                              <label class="form-check-label" for="select-all">
+                                  <strong>${__('Select All')}</strong></label
+                              >
+                          </div>
+                      `
+                    : ''}
+                <ul class="items-list">
+                    ${repeat(
+                        filtered_blocklist,
+                        (b) => b.get('jid'),
+                        (b) =>
+                            html`<li
+                                class="list-item"
+                                @click="${(ev) => {
+                                    if (ev.target.type === 'checkbox') return;
+                                    ev.preventDefault();
+                                    const checkbox = /** @type {HTMLInputElement} */ (
+                                        document.getElementById(`blocklist-${b.get('jid')}`)
+                                    );
+                                    checkbox.checked = !checkbox.checked;
+                                }}"
+                            >
+                                <div class="form-check">
+                                    <input
+                                        class="form-check-input"
+                                        type="checkbox"
+                                        id="blocklist-${b.get('jid')}"
+                                        name="${b.get('jid')}"
+                                    />
+                                    <label class="form-check-label w-100" for="blocklist-${b.get('jid')}">
+                                        ${b.get('jid')}
+                                    </label>
+                                </div>
+                            </li>`
+                    )}
+                </ul>
+            </form>
+        `;
+    } else {
+        return html`<p>${__('No blocked XMPP addresses')}</p>`;
+    }
+};

+ 7 - 2
src/plugins/rosterview/rosterview.js

@@ -49,12 +49,17 @@ export default class RosterView extends CustomElement {
 
     /** @param {MouseEvent} ev */
     showAddContactModal (ev) {
-        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 */
     showNewChatModal (ev) {
-        api.modal.show('converse-new-chat-modal', {'model': new Model()}, ev);
+        api.modal.show('converse-new-chat-modal', { model: new Model()}, ev);
+    }
+
+    /** @param {MouseEvent} ev */
+    showBlocklistModal(ev) {
+        api.modal.show('converse-blocklist-modal', {}, ev);
     }
 
     /** @param {MouseEvent} [ev] */

+ 13 - 0
src/plugins/rosterview/templates/roster.js

@@ -22,6 +22,7 @@ export default (el) => {
     const i18n_toggle_contacts = __('Click to toggle contacts');
     const i18n_title_add_contact = __('Add a contact');
     const i18n_title_new_chat = __('Start a new chat');
+    const i18n_show_blocklist = __('Show block list');
     const { state } = _converse;
     const roster = [
         ...(state.roster || []),
@@ -70,6 +71,18 @@ export default (el) => {
         `);
     }
 
+    btns.push(html`
+        <a
+            href="#"
+            class="dropdown-item" role="button"
+            @click="${(/** @type {MouseEvent} */ ev) => el.showBlocklistModal(ev)}"
+            title="${i18n_show_blocklist}"
+        >
+            <converse-icon class="fa fa-list-ul" size="1em"></converse-icon>
+            ${i18n_show_blocklist}
+        </a>
+    `);
+
     if (roster.length > 5) {
         btns.push(html`
             <a href="#"

+ 126 - 0
src/plugins/rosterview/tests/blocklist.js

@@ -0,0 +1,126 @@
+const { u } = converse.env;
+
+describe('The Blocklist Modal', function () {
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    it(
+        'shows a message when there are no blocked users',
+        mock.initConverse([], {}, async function (_converse) {
+            await mock.waitUntilBlocklistInitialized(_converse);
+            const modal = await _converse.api.modal.show('converse-blocklist-modal');
+            await u.waitUntil(() => modal.querySelector('p'));
+            expect(modal.querySelector('p').textContent.trim()).toBe('No blocked XMPP addresses');
+        })
+    );
+
+    it(
+        'shows blocked users and allows unblocking them',
+        mock.initConverse([], {}, async function (_converse) {
+            await mock.waitUntilBlocklistInitialized(_converse);
+
+            const { api } = _converse;
+            const connection = api.connection.get();
+            const { sent_stanzas } = connection;
+            const own_jid = _converse.session.get('jid');
+
+            // Need at least 6 users to show the filter
+            api.blocklist.add([
+                'user1@example.com',
+                'user2@example.com',
+                'user3@example.com',
+                'user4@example.com',
+                'user5@example.com',
+                'user6@example.com',
+            ]);
+            let stanza = await u.waitUntil(() => sent_stanzas.filter((s) => s.matches('iq[type="set"]')).pop());
+
+            expect(stanza).toEqualStanza(stx`
+                <iq type="set" id="${stanza.getAttribute('id')}" xmlns="jabber:client">
+                    <block xmlns="urn:xmpp:blocking">
+                        <item jid="user1@example.com"/>
+                        <item jid="user2@example.com"/>
+                        <item jid="user3@example.com"/>
+                        <item jid="user4@example.com"/>
+                        <item jid="user5@example.com"/>
+                        <item jid="user6@example.com"/>
+                    </block>
+                </iq>`);
+
+            const result = stx`
+                <iq type="result"
+                    id="${stanza.getAttribute('id')}"
+                    to="${own_jid}"
+                    xmlns="jabber:client"/>`;
+            connection._dataRecv(mock.createRequest(result));
+
+            const modal = await api.modal.show('converse-blocklist-modal');
+            await u.waitUntil(() => modal.querySelector('ul.items-list'));
+
+            // Verify users are shown
+            const items = modal.querySelectorAll('ul.items-list li');
+            expect(items.length).toBe(6);
+            expect(items[0].querySelector('label').textContent.trim()).toBe('user1@example.com');
+            expect(items[1].querySelector('label').textContent.trim()).toBe('user2@example.com');
+            expect(items[2].querySelector('label').textContent.trim()).toBe('user3@example.com');
+            expect(items[3].querySelector('label').textContent.trim()).toBe('user4@example.com');
+            expect(items[4].querySelector('label').textContent.trim()).toBe('user5@example.com');
+            expect(items[5].querySelector('label').textContent.trim()).toBe('user6@example.com');
+
+            // Test filtering
+            const input = modal.querySelector('input[name="blocklist_filter"]');
+            input.value = 'user1';
+            input.dispatchEvent(new Event('input'));
+
+            await u.waitUntil(() => modal.querySelectorAll('ul.items-list li').length === 1);
+            expect(modal.querySelector('ul.items-list li label').textContent.trim()).toBe('user1@example.com');
+
+            // Clear filter
+            modal.querySelector('button.btn-outline-secondary').click();
+            await u.waitUntil(() => modal.querySelectorAll('ul.items-list li').length === 6);
+
+            // Test select all
+            const selectAll = modal.querySelector('#select-all');
+            selectAll.click();
+
+            const checkboxes = modal.querySelectorAll('input[type="checkbox"]');
+            checkboxes.forEach((cb) => {
+                expect(cb.checked).toBe(true);
+            });
+
+            while (sent_stanzas.length) sent_stanzas.pop();
+
+            // Test unblocking
+            spyOn(api.toast, 'show').and.callThrough();
+            const form = modal.querySelector('form');
+            form.dispatchEvent(new Event('submit', { bubbles: true }));
+
+            // Mock unblock response
+            stanza = await u.waitUntil(() => sent_stanzas.filter((s) => s.matches('iq[type="set"]')).pop());
+
+            expect(stanza).toEqualStanza(stx`
+                <iq type="set" id="${stanza.getAttribute('id')}" xmlns="jabber:client">
+                    <unblock xmlns="urn:xmpp:blocking">
+                        <item jid="user1@example.com"/>
+                        <item jid="user2@example.com"/>
+                        <item jid="user3@example.com"/>
+                        <item jid="user4@example.com"/>
+                        <item jid="user5@example.com"/>
+                        <item jid="user6@example.com"/>
+                    </unblock>
+                </iq>`);
+
+            const unblock_result = stx`
+                <iq type="result"
+                    id="${stanza.getAttribute('id')}"
+                    to="${own_jid}"
+                    xmlns="jabber:client"/>`;
+            connection._dataRecv(mock.createRequest(unblock_result));
+
+            await u.waitUntil(() => api.toast.show.calls.count() === 1);
+            expect(api.toast.show).toHaveBeenCalledWith('blocked', {
+                type: 'success',
+                body: 'Successfully unblocked 6 XMPP addresses',
+            });
+        })
+    );
+});

+ 1 - 1
src/plugins/rosterview/utils.js

@@ -85,7 +85,7 @@ export async function blockContact(contact) {
     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;
+    if (!(await api.confirm(__('Confirm'), i18n_confirm))) return false;
 
     (await api.chats.get(contact.get('jid')))?.close();
 

+ 1 - 1
src/shared/modals/templates/user-details.js

@@ -44,7 +44,7 @@ function tplBlockButton(el) {
     const i18n_block = __('Add to blocklist');
     return html`
         <button type="button" @click="${(ev) => el.blockContact(ev)}" class="btn btn-danger">
-            <converse-icon class="fas fa-times" color="var(--background-color)" size="1em"></converse-icon
+            <converse-icon class="fas fa-list-ul" color="var(--background-color)" size="1em"></converse-icon
             >&nbsp;${i18n_block}
         </button>
     `;

+ 1 - 1
src/shared/styles/lists.scss

@@ -31,7 +31,7 @@
             border: none;
             clear: both;
             color: var(--text-color);
-            padding: 0.5em 0;
+            padding: 0.5em;
             word-wrap: break-word;
 
             &.unread-msgs {

+ 4 - 0
src/shared/styles/themes/cyberpunk.scss

@@ -60,6 +60,10 @@
     --converse-highlight-color: var(--yellow);
     --converse-secondary-color: var(--secondary-color);
 
+    .form-check-input:checked {
+        background-color: var(--purple);
+        border-color: var(--purple);
+    }
     .shadow-lg {
         --converse-box-shadow-lg: 0 1rem 3rem var(--background-color);
     }

+ 5 - 0
src/shared/styles/themes/dracula.scss

@@ -54,6 +54,11 @@
     --converse-body-color: var(--foreground-color) !important;
     --converse-highlight-color: var(--yellow) !important;
     --converse-secondary-color: var(--secondary-color);
+
+    .form-check-input:checked {
+        background-color: var(--purple);
+        border-color: var(--purple);
+    }
     .navbar-nav {
         --converse-nav-link-color: var(--link-color) !important;
         --converse-nav-link-hover-color: var(--link-color-hover) !important;

+ 6 - 0
src/shared/styles/themes/nordic.scss

@@ -66,6 +66,12 @@
     --converse-body-bg: var(--background-color);
     --converse-body-color: var(--foreground-color) !important;
     --converse-highlight-color: var(--red);
+    --converse-border-color: lightgray;
+
+    .form-check-input:checked {
+        background-color: var(--purple);
+        border-color: var(--purple);
+    }
     .navbar-nav {
         --converse-nav-link-color: var(--link-color) !important;
     }

+ 26 - 0
src/types/plugins/rosterview/modals/blocklist.d.ts

@@ -0,0 +1,26 @@
+export default class BlockListModal extends BaseModal {
+    static get properties(): {
+        filter_text: {
+            type: StringConstructor;
+        };
+        model: {
+            type: typeof import("@converse/skeletor").Model;
+        };
+    };
+    constructor();
+    filter_text: string;
+    initialize(): Promise<void>;
+    blocklist: any;
+    renderModal(): import("lit-html").TemplateResult<1>;
+    getModalTitle(): any;
+    /**
+     * @param {MouseEvent} ev
+     */
+    unblockUsers(ev: MouseEvent): Promise<void>;
+    /**
+     * @param {MouseEvent} ev
+     */
+    toggleSelectAll(ev: MouseEvent): void;
+}
+import BaseModal from 'plugins/modal/modal.js';
+//# sourceMappingURL=blocklist.d.ts.map

+ 3 - 0
src/types/plugins/rosterview/modals/templates/blocklist.d.ts

@@ -0,0 +1,3 @@
+declare function _default(el: import("../blocklist.js").default): import("lit-html").TemplateResult<1>;
+export default _default;
+//# sourceMappingURL=blocklist.d.ts.map

+ 2 - 0
src/types/plugins/rosterview/rosterview.d.ts

@@ -11,6 +11,8 @@ export default class RosterView extends CustomElement {
     showAddContactModal(ev: MouseEvent): void;
     /** @param {MouseEvent} ev */
     showNewChatModal(ev: MouseEvent): void;
+    /** @param {MouseEvent} ev */
+    showBlocklistModal(ev: MouseEvent): void;
     /** @param {MouseEvent} [ev] */
     syncContacts(ev?: MouseEvent): Promise<void>;
     syncing_contacts: boolean;

+ 2 - 1
tsconfig.json

@@ -19,7 +19,8 @@
 
     "lib": [
       "ES2020",
-      "dom"
+      "dom",
+      "dom.iterable"
     ],
 
     "allowJs": true,