Ver código fonte

Show confirmation alert in the chat with requesting contacts

JC Brand 1 mês atrás
pai
commit
34541c9f43

+ 2 - 1
src/entry.js

@@ -45,7 +45,8 @@ const converse = {
     /**
      * Public API method which explicitly loads Converse and allows you the
      * possibility to pass in configuration settings which need to be defined
-     * before loading. Currently this is only the [assets_path](https://conversejs.org/docs/html/configuration.html#assets_path)
+     * before loading. Currently this is only the
+     * [assets_path](https://conversejs.org/docs/html/configuration.html#assets_path)
      * setting.
      *
      * If not called explicitly, this method will be called implicitly once

+ 1 - 0
src/headless/plugins/roster/contact.js

@@ -125,6 +125,7 @@ class RosterContact extends ModelWithVCard(ColorAwareModel(Model)) {
      */
     unauthorize (message) {
         rejectPresenceSubscription(this.get('jid'), message);
+        this.save({ requesting: false });
         return this;
     }
 

+ 2 - 1
src/headless/shared/model-with-contact.js

@@ -71,10 +71,11 @@ export default function ModelWithContact(BaseModel) {
 
                 this.listenTo(this.contact, 'destroy', () => {
                     delete this.contact;
+                    this.trigger('contact:destroy');
                 });
 
                 this.rosterContactAdded.resolve();
-                this.trigger('contactAdded', this.contact);
+                this.trigger('contact:add', this.contact);
             }
         }
     };

+ 5 - 2
src/plugins/chatview/chat.js

@@ -18,8 +18,12 @@ export default class ChatView extends DragResizable(BaseChatView) {
         const { chatboxviews, chatboxes } = _converse.state;
         chatboxviews.add(this.jid, this);
         this.model = chatboxes.get(this.jid);
+        this.listenTo(this.model, 'change:requesting', () => this.requestUpdate());
         this.listenTo(this.model, 'change:hidden', () => !this.model.get('hidden') && this.afterShown());
         this.listenTo(this.model, 'change:show_help_messages', () => this.requestUpdate());
+        this.listenTo(this.model, 'contact:add', () => this.requestUpdate());
+        this.listenTo(this.model, 'contact:change', () => this.requestUpdate());
+        this.listenTo(this.model, 'contact:destroy', () => this.requestUpdate());
 
         document.addEventListener('visibilitychange', () => this.onWindowStateChanged());
 
@@ -39,7 +43,6 @@ export default class ChatView extends DragResizable(BaseChatView) {
     }
 
     getHelpMessages() {
-        // eslint-disable-line class-methods-use-this
         return [
             `<strong>/clear</strong>: ${__('Remove messages')}`,
             `<strong>/close</strong>: ${__('Close this chat')}`,
@@ -53,6 +56,6 @@ export default class ChatView extends DragResizable(BaseChatView) {
         this.model.clearUnreadMsgCounter();
         this.maybeFocus();
     }
-};
+}
 
 api.elements.define('converse-chat', ChatView);

+ 1 - 2
src/plugins/chatview/styles/chatbox.scss

@@ -101,7 +101,7 @@
             display: flex;
             flex-direction: column;
             justify-content: space-between;
-            background-color: var(--chat-textarea-background-color);
+            background-color: var(--background-color);
             border-bottom-left-radius: var(--chatbox-border-radius);
             border-bottom-right-radius: var(--chatbox-border-radius);
 
@@ -141,7 +141,6 @@
             margin-bottom: 0.25em;
         }
         .chat-content {
-            background-color: var(--background-color);
             border: 0;
             color: var(--text-color);
             font-size: var(--message-font-size);

+ 6 - 0
src/plugins/chatview/templates/chat.js

@@ -1,5 +1,6 @@
 import { html, nothing } from 'lit';
 import { api, constants } from '@converse/headless';
+import { __ } from 'i18n';
 import { getChatStyle } from 'shared/chat/utils';
 
 const { CHATROOMS_TYPE } = constants;
@@ -12,6 +13,7 @@ export default (el) => {
     const show_help_messages = el.model.get('show_help_messages');
     const is_overlayed = api.settings.get('view_mode') === 'overlayed';
     const style = getChatStyle(el.model);
+    const requesting = el.model.contact?.get('requesting');
     return html`
         <div class="flyout box-flyout" style="${style || nothing}">
             ${is_overlayed ? html`<converse-dragresize></converse-dragresize>` : ''}
@@ -22,6 +24,10 @@ export default (el) => {
                           class="chat-head chat-head-chatbox row g-0"
                       ></converse-chat-heading>
                       <div class="chat-body">
+                          ${requesting
+                              ? html`<converse-contact-approval-alert .contact="${el.model.contact}">
+                                </converse-contact-approval-alert>`
+                              : ''}
                           <div
                               class="chat-content ${el.model.get('show_send_button') ? 'chat-content-sendbutton' : ''}"
                               aria-live="polite"

+ 39 - 0
src/plugins/rosterview/approval-alert.js

@@ -0,0 +1,39 @@
+import { RosterContact, _converse, api } from '@converse/headless';
+import { CustomElement } from 'shared/components/element';
+import { declineContactRequest } from 'plugins/rosterview/utils.js';
+import tplApprovalAlert from './templates/approval-alert.js';
+
+import './styles/approval-alert.scss';
+
+export default class ContactApprovalAlert extends CustomElement {
+    static properties = {
+        contact: { type: RosterContact },
+    };
+
+    constructor() {
+        super();
+        this.contact = null;
+    }
+
+    render() {
+        return tplApprovalAlert(this);
+    }
+
+    /**
+     * @param {MouseEvent} ev
+     */
+    async acceptRequest(ev) {
+        ev?.preventDefault?.();
+        api.modal.show('converse-accept-contact-request-modal', { contact: this.contact }, ev);
+    }
+
+    /**
+     * @param {MouseEvent} ev
+     */
+    async declineRequest(ev) {
+        ev?.preventDefault?.();
+        declineContactRequest(this.contact);
+    }
+}
+
+api.elements.define('converse-contact-approval-alert', ContactApprovalAlert);

+ 1 - 0
src/plugins/rosterview/index.js

@@ -12,6 +12,7 @@ import NewChatModal from './modals/new-chat.js';
 import BlockListModal from './modals/blocklist.js';
 import RosterView from './rosterview.js';
 
+import './approval-alert.js';
 import 'shared/styles/status.scss';
 import './styles/roster.scss';
 

+ 6 - 0
src/plugins/rosterview/styles/approval-alert.scss

@@ -0,0 +1,6 @@
+.chat-body {
+    converse-contact-approval-alert {
+        margin: 0.5em;
+    }
+}
+

+ 31 - 0
src/plugins/rosterview/templates/approval-alert.js

@@ -0,0 +1,31 @@
+import { html } from 'lit';
+import { __ } from 'i18n';
+
+/**
+ * @param {import('../approval-alert').default} el
+ */
+export default (el) => {
+    return el.contact
+        ? html`
+              <div class="alert alert-info d-flex flex-column align-items-center mb-0 p-3 text-center">
+                  <p class="mb-2">${__('%1$s would like to be your contact', el.contact.getDisplayName())}</p>
+                  <div class="btn-group">
+                      <button
+                          type="button"
+                          class="btn btn-sm btn-success"
+                          @click=${/** @param {MouseEvent} ev */ (ev) => el.acceptRequest(ev)}
+                      >
+                          ${__('Approve')}
+                      </button>
+                      <button
+                          type="button"
+                          class="btn btn-sm btn-danger"
+                          @click=${/** @param {MouseEvent} ev */ (ev) => el.declineRequest(ev)}
+                      >
+                          ${__('Deny')}
+                      </button>
+                  </div>
+              </div>
+          `
+        : '';
+};

+ 109 - 6
src/plugins/rosterview/tests/requesting_contacts.js

@@ -73,9 +73,7 @@ describe('Requesting Contacts', function () {
             const { roster } = _converse;
             const contact = roster.get(jid);
             spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
-            spyOn(contact, 'unauthorize').and.callFake(function () {
-                return contact;
-            });
+            spyOn(contact, 'unauthorize').and.callFake(() => contact);
             const roster_el = document.querySelector('converse-roster');
             const decline_btn = roster_el.querySelector('.dropdown-item.decline-xmpp-request');
             decline_btn.click();
@@ -212,9 +210,7 @@ describe('Requesting Contacts', function () {
             const jid = name.replace(/ /g, '.').toLowerCase() + '@montague.lit';
             const contact = _converse.roster.get(jid);
             spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
-            spyOn(contact, 'unauthorize').and.callFake(function () {
-                return contact;
-            });
+            spyOn(contact, 'unauthorize').and.callFake(() => contact);
             const req_contact = await u.waitUntil(() =>
                 sizzle(".contact-name:contains('" + name + "')", rosterview).pop()
             );
@@ -274,3 +270,110 @@ describe('Requesting Contacts', function () {
         })
     );
 });
+
+describe('A chat with a requesting contact', function () {
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    it(
+        'shows an approval alert when chatting with a requesting contact',
+        mock.initConverse([], { lazy_load_vcards: false }, async function (_converse) {
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.createContacts(_converse, 'requesting', 1);
+            const name = mock.req_names[0];
+            const jid = mock.req_jids[0];
+
+            // Open chat with requesting contact
+            const view = await mock.openChatBoxFor(_converse, jid);
+            await u.waitUntil(() => view.querySelector('converse-contact-approval-alert'));
+
+            const alert = view.querySelector('converse-contact-approval-alert');
+            expect(alert).toBeTruthy();
+            expect(alert.textContent).toContain(`${name} would like to be your contact`);
+            expect(alert.querySelector('.btn-success')).toBeTruthy();
+            expect(alert.querySelector('.btn-danger')).toBeTruthy();
+        })
+    );
+
+    it(
+        'can approve a contact request via the approval alert',
+        mock.initConverse([], { lazy_load_vcards: false }, async function (_converse) {
+            const { api } = _converse;
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.createContacts(_converse, 'requesting', 1);
+            const jid = mock.req_jids[0];
+            const contact = _converse.roster.get(jid);
+            spyOn(contact, 'authorize').and.callThrough();
+
+            const view = await mock.openChatBoxFor(_converse, jid);
+            await u.waitUntil(() => view.querySelector('converse-contact-approval-alert'));
+
+            const alert = view.querySelector('converse-contact-approval-alert');
+            alert.querySelector('.btn-success').click();
+
+            const modal = api.modal.get('converse-accept-contact-request-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
+
+            // Submit the approval modal
+            const sent_stanzas = api.connection.get().sent_stanzas;
+            while (sent_stanzas.length) sent_stanzas.pop();
+            modal.querySelector('button[type="submit"]').click();
+
+            let stanza = await u.waitUntil(() => sent_stanzas.filter((s) => s.matches('iq[type="set"]')).pop());
+            expect(stanza).toEqualStanza(
+                stx`<iq type="set" xmlns="jabber:client" id="${stanza.getAttribute('id')}">
+                        <query xmlns="jabber:iq:roster">
+                            <item jid="${contact.get('jid')}" name="Escalus, prince of Verona"></item>
+                        </query>
+                    </iq>`
+            );
+
+            const result = stx`
+                <iq to="${api.connection.get().jid}" type="result" id="${stanza.getAttribute('id')}" xmlns="jabber:client"/>`;
+            api.connection.get()._dataRecv(mock.createRequest(result));
+
+            stanza = await u.waitUntil(() =>
+                sent_stanzas.filter((s) => s.matches('presence[type="subscribed"]')).pop()
+            );
+            expect(stanza).toEqualStanza(
+                stx`<presence to="${contact.get('jid')}" type="subscribed" xmlns="jabber:client"/>`
+            );
+
+            await u.waitUntil(() => contact.authorize.calls.count());
+            expect(contact.authorize).toHaveBeenCalled();
+        })
+    );
+
+    it(
+        'can deny a contact request via the approval alert',
+        mock.initConverse([], {}, async function (_converse) {
+            const { api } = _converse;
+            await mock.waitUntilDiscoConfirmed(
+                _converse,
+                _converse.domain,
+                [{ 'category': 'server', 'type': 'IM' }],
+                ['urn:xmpp:blocking']
+            );
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.createContacts(_converse, 'requesting', 1);
+            const jid = mock.req_jids[0];
+            spyOn(api, 'confirm').and.returnValue(Promise.resolve(true));
+
+            const view = await mock.openChatBoxFor(_converse, jid);
+            await u.waitUntil(() => view.querySelector('converse-contact-approval-alert'));
+
+            const sent_stanzas = api.connection.get().sent_stanzas;
+            while (sent_stanzas.length) sent_stanzas.pop();
+
+            const alert = view.querySelector('converse-contact-approval-alert');
+            alert.querySelector('.btn-danger').click();
+
+            await u.waitUntil(() => api.confirm.calls.count());
+
+            let stanza = await u.waitUntil(() =>
+                sent_stanzas.filter((s) => s.matches('presence[type="unsubscribed"]')).pop()
+            );
+            expect(stanza).toEqualStanza(stx`<presence to="${jid}" type="unsubscribed" xmlns="jabber:client"/>`);
+            await u.waitUntil(() => view.querySelector('converse-contact-approval-alert') === null);
+        })
+    );
+});

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

@@ -56,7 +56,6 @@ export async function declineContactRequest(contact) {
 
     if (result) {
         const chat = await api.chats.get(contact.get('jid'));
-        chat?.close();
         contact.unauthorize();
 
         if (blocking_supported && Array.isArray(result) && result.find((i) => i.name === 'block')?.value === 'on') {
@@ -65,13 +64,14 @@ export async function declineContactRequest(contact) {
                 type: 'success',
                 body: __('Contact request declined and user blocked'),
             });
+            chat?.close();
         } else {
             api.toast.show('request-declined', {
                 type: 'success',
                 body: __('Contact request declined'),
             });
         }
-        contact.destroy();
+        if (!chat) contact.destroy();
     }
     return this;
 }

+ 4 - 0
src/shared/styles/alerts.scss

@@ -14,6 +14,10 @@
             font-size: large;
         }
 
+        p {
+            color: var(--background-color) !important;
+        }
+
         .modal-title {
             font-size: 110%;
         }

+ 8 - 2
src/shared/tests/mock.js

@@ -529,7 +529,7 @@ async function waitForRoster (_converse, type='current', length=-1, include_nick
     if (type === 'pending' || type === 'all') {
         ((length > -1) ? pend_names.slice(0, length) : pend_names).map(name =>
             result.c('item', {
-                jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                jid: `${name.replace(/ /g,'.').toLowerCase()}@${domain}`,
                 name: include_nick ? name : undefined,
                 subscription: 'none',
                 ask: 'subscribe'
@@ -541,7 +541,7 @@ async function waitForRoster (_converse, type='current', length=-1, include_nick
         const names = (length > -1) ? cur_names.slice(0, length) : cur_names;
         names.forEach(name => {
             result.c('item', {
-                jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                jid: `${name.replace(/ /g,'.').toLowerCase()}@${domain}`,
                 name: include_nick ? name : undefined,
                 subscription: 'both',
                 ask: null
@@ -653,10 +653,15 @@ const default_muc_features = [
 
 const view_mode = 'overlayed';
 
+const domain = 'montague.lit';
+
 // Names from http://www.fakenamegenerator.com/
 const req_names = [
     'Escalus, prince of Verona', 'The Nurse', 'Paris'
 ];
+
+const req_jids = req_names.map((name) => `${name.replace(/ /g, '.').toLowerCase()}@${domain}`);
+
 const pend_names = [
     'Lord Capulet', 'Guard', 'Servant'
 ];
@@ -976,6 +981,7 @@ Object.assign(mock, {
     pend_names,
     receiveOwnMUCPresence,
     req_names,
+    req_jids,
     returnMemberLists,
     sendMessage,
     toggleControlBox,

+ 2 - 1
src/types/entry.d.ts

@@ -20,7 +20,8 @@ declare namespace converse {
     /**
      * Public API method which explicitly loads Converse and allows you the
      * possibility to pass in configuration settings which need to be defined
-     * before loading. Currently this is only the [assets_path](https://conversejs.org/docs/html/configuration.html#assets_path)
+     * before loading. Currently this is only the
+     * [assets_path](https://conversejs.org/docs/html/configuration.html#assets_path)
      * setting.
      *
      * If not called explicitly, this method will be called implicitly once

+ 1 - 6
src/types/plugins/chatview/chat.d.ts

@@ -20,12 +20,7 @@ declare const ChatView_base: {
         initialize(): any;
         connectedCallback(): any;
         disconnectedCallback(): void;
-        on(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options? /**
-         * Triggered once the {@link ChatView} has been initialized
-         * @event _converse#chatBoxViewInitialized
-         * @type {ChatView}
-         * @example _converse.api.listen.on('chatBoxViewInitialized', view => { ... });
-         */: Record<string, any>) => any, context: any): any;
+        on(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any, context: any): any;
         _events: any;
         _listeners: {};
         listenTo(obj: any, name: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any): any;

+ 20 - 0
src/types/plugins/rosterview/approval-alert.d.ts

@@ -0,0 +1,20 @@
+export default class ContactApprovalAlert extends CustomElement {
+    static properties: {
+        contact: {
+            type: typeof RosterContact;
+        };
+    };
+    contact: any;
+    render(): import("lit-html").TemplateResult<1>;
+    /**
+     * @param {MouseEvent} ev
+     */
+    acceptRequest(ev: MouseEvent): Promise<void>;
+    /**
+     * @param {MouseEvent} ev
+     */
+    declineRequest(ev: MouseEvent): Promise<void>;
+}
+import { CustomElement } from 'shared/components/element';
+import { RosterContact } from '@converse/headless';
+//# sourceMappingURL=approval-alert.d.ts.map

+ 3 - 0
src/types/plugins/rosterview/templates/approval-alert.d.ts

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