Sfoglia il codice sorgente

feat: show approval alert in chats with unsaved contacts

JC Brand 1 mese fa
parent
commit
c4c322def1

+ 6 - 0
CHANGES.md

@@ -1,5 +1,11 @@
 # Changelog
 
+## 11.0.2 (Unreleased)
+
+- #3700: Fix exception that occurs when optional cp attribute is missing
+- Add approval banner in chats with requesting contacts or unsaved contacts
+- Some fixes regarding manually resized chats in `overlayed` view mode.
+
 ## 11.0.1 (2025-06-09)
 
 - #2938: Add a service discovery browser

+ 7 - 5
src/headless/plugins/roster/plugin.js

@@ -3,6 +3,7 @@
  * @license Mozilla Public License (MPLv2)
  */
 import '../../plugins/status/index.js';
+import u from '../../utils/index.js';
 import RosterContact from './contact.js';
 import RosterContacts from './contacts.js';
 import _converse from '../../shared/_converse.js';
@@ -12,6 +13,7 @@ import roster_api from './api.js';
 import Presence from './presence.js';
 import Presences from './presences.js';
 import {
+    isUnsavedContact,
     onChatBoxesInitialized,
     onClearSession,
     onPresencesInitialized,
@@ -20,17 +22,16 @@ import {
     unregisterPresenceHandler,
 } from './utils.js';
 
-
 converse.plugins.add('converse-roster', {
     dependencies: ['converse-status'],
 
-    initialize () {
+    initialize() {
         api.settings.extend({
             allow_contact_requests: true,
             auto_subscribe: false,
             enable_roster_versioning: true,
             show_self_in_roster: true,
-            synchronize_availability: true
+            synchronize_availability: true,
         });
 
         api.promises.add([
@@ -43,6 +44,7 @@ converse.plugins.add('converse-roster', {
 
         // API methods only available to plugins
         Object.assign(_converse.api, roster_api);
+        Object.assign(u, { roster: { isUnsavedContact } });
 
         const { __ } = _converse;
         const labels = {
@@ -57,7 +59,7 @@ converse.plugins.add('converse-roster', {
         Object.assign(_converse.labels, labels);
 
         const exports = { Presence, Presences, RosterContact, RosterContacts };
-        Object.assign(_converse, exports);  // XXX DEPRECATED
+        Object.assign(_converse, exports); // XXX DEPRECATED
         Object.assign(_converse.exports, exports);
 
         api.listen.on('beforeTearDown', () => unregisterPresenceHandler());
@@ -68,5 +70,5 @@ converse.plugins.add('converse-roster', {
         api.listen.on('streamResumptionFailed', () => _converse.session.set('roster_cached', false));
 
         api.waitUntil('rosterContactsFetched').then(onRosterContactsFetched);
-    }
+    },
 });

+ 1 - 2
src/headless/plugins/roster/utils.js

@@ -229,7 +229,6 @@ export function rejectPresenceSubscription(jid, message) {
  * @return {boolean}
  */
 export function isUnsavedContact(contact) {
-    const bare_jid = _converse.session.get('bare_jid');
-    const is_self = bare_jid !== contact.get('jid');
+    const is_self = _converse.session.get('bare_jid') === contact.get('jid');
     return !is_self && !contact.get('subscription');
 }

+ 1 - 0
src/headless/utils/index.js

@@ -32,6 +32,7 @@ import * as url from './url.js';
 const u = {
     muc: null,
     mam: null,
+    roster: null,
 };
 
 /**

+ 1 - 2
src/plugins/chatview/templates/chat.js

@@ -13,7 +13,6 @@ 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>` : ''}
@@ -24,7 +23,7 @@ export default (el) => {
                           class="chat-head chat-head-chatbox row g-0"
                       ></converse-chat-heading>
                       <div class="chat-body">
-                          ${requesting
+                          ${el.model.contact
                               ? html`<converse-contact-approval-alert .contact="${el.model.contact}">
                                 </converse-contact-approval-alert>`
                               : ''}

+ 34 - 4
src/plugins/rosterview/approval-alert.js

@@ -1,7 +1,10 @@
-import { RosterContact, _converse, api } from '@converse/headless';
+import { RosterContact, _converse, api, converse} from '@converse/headless';
 import { CustomElement } from 'shared/components/element';
 import { declineContactRequest } from 'plugins/rosterview/utils.js';
 import tplApprovalAlert from './templates/approval-alert.js';
+import tplUnsavedAlert from './templates/unsaved-alert.js';
+
+const { u } = converse.env;
 
 import './styles/approval-alert.scss';
 
@@ -15,15 +18,26 @@ export default class ContactApprovalAlert extends CustomElement {
         this.contact = null;
     }
 
+    initialize() {
+        super.initialize();
+        this.listenTo(this.contact, 'change', () => this.requestUpdate());
+    }
+
     render() {
-        return tplApprovalAlert(this);
+        if (this.contact.get('requesting')) {
+            return tplApprovalAlert(this);
+        } else if (u.roster.isUnsavedContact(this.contact)) {
+            if (this.contact.get('hide_contact_add_alert')) return '';
+            return tplUnsavedAlert(this);
+        }
+        return '';
     }
 
     /**
      * @param {MouseEvent} ev
      */
     async acceptRequest(ev) {
-        ev?.preventDefault?.();
+        ev.preventDefault();
         api.modal.show('converse-accept-contact-request-modal', { contact: this.contact }, ev);
     }
 
@@ -31,9 +45,25 @@ export default class ContactApprovalAlert extends CustomElement {
      * @param {MouseEvent} ev
      */
     async declineRequest(ev) {
-        ev?.preventDefault?.();
+        ev.preventDefault();
         declineContactRequest(this.contact);
     }
+
+    /**
+     * @param {MouseEvent} ev
+     * */
+    showAddContactModal(ev) {
+        ev.preventDefault();
+        api.modal.show('converse-add-contact-modal', { contact: this.contact }, ev);
+    }
+
+    /**
+     * @param {MouseEvent} ev
+     */
+    async close(ev) {
+        ev.preventDefault();
+        this.contact.save({ hide_contact_add_alert: true });
+    }
 }
 
 api.elements.define('converse-contact-approval-alert', ContactApprovalAlert);

+ 31 - 0
src/plugins/rosterview/templates/unsaved-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">${__('Would you like to add %1$s as a contact?', el.contact.getDisplayName())}</p>
+                  <div class="btn-group">
+                      <button
+                          type="button"
+                          class="btn btn-sm btn-success"
+                          @click=${/** @param {MouseEvent} ev */ (ev) => el.showAddContactModal(ev)}
+                      >
+                          ${__('Add')}
+                      </button>
+                      <button
+                          type="button"
+                          class="btn btn-sm btn-danger"
+                          @click=${/** @param {MouseEvent} ev */ (ev) => el.close(ev)}
+                      >
+                          ${__('Dismiss')}
+                      </button>
+                  </div>
+              </div>
+          `
+        : '';
+};

+ 1 - 1
src/plugins/rosterview/tests/requesting_contacts.js

@@ -373,7 +373,7 @@ describe('A chat with a requesting contact', function () {
                 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);
+            await u.waitUntil(() => !view.querySelector('converse-contact-approval-alert').childElementCound);
         })
     );
 });

+ 127 - 4
src/plugins/rosterview/tests/unsaved-contacts.js

@@ -1,4 +1,4 @@
-const { stx, u } = converse.env;
+const { stx, u, sizzle } = converse.env;
 
 describe('An unsaved Contact', function () {
     it(
@@ -21,7 +21,9 @@ describe('An unsaved Contact', function () {
             await _converse.handleMessageStanza(msg);
 
             const rosterview = document.querySelector('converse-roster');
-            await u.waitUntil(() => rosterview.querySelectorAll(`ul[data-group="Unsaved contacts"] li .open-chat`).length);
+            await u.waitUntil(
+                () => rosterview.querySelectorAll(`ul[data-group="Unsaved contacts"] li .open-chat`).length
+            );
             expect(rosterview.querySelectorAll(`ul[data-group="Unsaved contacts"] li .open-chat`).length).toBe(1);
             const el = rosterview.querySelector(`ul[data-group="Unsaved contacts"] li .contact-name`);
             expect(el.textContent).toBe('Mercutio');
@@ -63,7 +65,9 @@ describe('An unsaved Contact', function () {
                 </message>`;
             await _converse.handleMessageStanza(msg);
 
-            await u.waitUntil(() => rosterview.querySelectorAll(`ul[data-group="Unsaved contacts"] li .open-chat`).length);
+            await u.waitUntil(
+                () => rosterview.querySelectorAll(`ul[data-group="Unsaved contacts"] li .open-chat`).length
+            );
             expect(rosterview.querySelectorAll(`ul[data-group="Unsaved contacts"] li .open-chat`).length).toBe(1);
             const el = rosterview.querySelector(`ul[data-group="Unsaved contacts"] li .contact-name`);
             expect(el.textContent).toBe('Mercutio');
@@ -90,7 +94,9 @@ describe('An unsaved Contact', function () {
             await _converse.handleMessageStanza(msg);
 
             const rosterview = document.querySelector('converse-roster');
-            await u.waitUntil(() => rosterview.querySelectorAll(`ul[data-group="Unsaved contacts"] li .open-chat`).length);
+            await u.waitUntil(
+                () => rosterview.querySelectorAll(`ul[data-group="Unsaved contacts"] li .open-chat`).length
+            );
             expect(rosterview.querySelectorAll(`ul[data-group="Unsaved contacts"] li .open-chat`).length).toBe(1);
             const el = rosterview.querySelector(`ul[data-group="Unsaved contacts"] li .contact-name`);
             expect(el.textContent).toBe('Mercutio');
@@ -101,3 +107,120 @@ describe('An unsaved Contact', function () {
         })
     );
 });
+
+describe('A chat with an unsaved contact', function () {
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    it(
+        'shows an unsaved contact alert when chatting with an unsaved contact',
+        mock.initConverse([], { lazy_load_vcards: false }, async function (_converse) {
+            const { api } = _converse;
+            await mock.waitUntilBlocklistInitialized(_converse);
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.openControlBox(_converse);
+
+            const sender_jid = mock.cur_jids[0];
+            const msg = stx`
+                <message xmlns='jabber:client'
+                        id='${api.connection.get().getUniqueId()}'
+                        to='${_converse.bare_jid}'
+                        from='${sender_jid}'
+                        type='chat'>
+                    <body>Hello</body>
+                </message>`;
+            await _converse.exports.handleMessageStanza(msg);
+
+            const view = await mock.openChatBoxFor(_converse, sender_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('Would you like to add Mercutio as a contact?');
+            expect(alert.querySelector('.btn-success')).toBeTruthy();
+            expect(alert.querySelector('.btn-danger')).toBeTruthy();
+        })
+    );
+
+    it(
+        'can add an unsaved contact via the alert',
+        mock.initConverse([], { lazy_load_vcards: false }, async function (_converse) {
+            const { api } = _converse;
+            await mock.waitUntilBlocklistInitialized(_converse);
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.openControlBox(_converse);
+
+            const sender_jid = mock.cur_jids[0];
+            const msg = stx`
+                <message xmlns='jabber:client'
+                        id='${api.connection.get().getUniqueId()}'
+                        to='${_converse.bare_jid}'
+                        from='${sender_jid}'
+                        type='chat'>
+                    <body>Hello</body>
+                </message>`;
+            await _converse.handleMessageStanza(msg);
+
+            const view = await mock.openChatBoxFor(_converse, sender_jid);
+            await u.waitUntil(() => view.querySelector('converse-contact-approval-alert'));
+
+            const contact = _converse.roster.get(sender_jid);
+            spyOn(contact, 'subscribe').and.callThrough();
+
+            const alert = view.querySelector('converse-contact-approval-alert');
+            alert.querySelector('.btn-success').click();
+
+            const modal = api.modal.get('converse-add-contact-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
+
+            // Submit the add contact modal
+            const sent_stanzas = api.connection.get().sent_stanzas;
+            while (sent_stanzas.length) sent_stanzas.pop();
+            modal.querySelector('button[type="submit"]').click();
+
+            const sent_stanza = await u.waitUntil(() =>
+                sent_stanzas
+                    .filter((iq) => sizzle(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`, iq).length)
+                    .pop()
+            );
+            expect(sent_stanza).toEqualStanza(stx`
+                <iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">
+                    <query xmlns="jabber:iq:roster">
+                        <item jid="${sender_jid}"></item>
+                    </query>
+                </iq>`);
+        })
+    );
+
+    it(
+        'can dismiss the unsaved contact alert',
+        mock.initConverse([], {}, async function (_converse) {
+            const { api } = _converse;
+            await mock.waitUntilBlocklistInitialized(_converse);
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.openControlBox(_converse);
+
+            const sender_jid = mock.cur_jids[0];
+            const msg = stx`
+                <message xmlns='jabber:client'
+                        id='${api.connection.get().getUniqueId()}'
+                        to='${_converse.bare_jid}'
+                        from='${sender_jid}'
+                        type='chat'>
+                    <body>Hello</body>
+                </message>`;
+            await _converse.handleMessageStanza(msg);
+
+            const view = await mock.openChatBoxFor(_converse, sender_jid);
+            const alert = await u.waitUntil(() => view.querySelector('converse-contact-approval-alert'));
+
+            const contact = _converse.roster.get(sender_jid);
+            spyOn(contact, 'save').and.callThrough();
+
+            alert.querySelector('.btn-danger').click();
+
+            await u.waitUntil(() => contact.save.calls.count());
+            expect(contact.save).toHaveBeenCalledWith({ hide_contact_add_alert: true });
+            await u.waitUntil(() => !view.querySelector('converse-contact-approval-alert').childElementCound);
+        })
+    );
+});

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

@@ -660,7 +660,6 @@ 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'
@@ -683,6 +682,7 @@ const current_contacts_map = {
     'Friar John': []
 }
 
+
 const map = current_contacts_map;
 const groups_map = {};
 Object.keys(map).forEach(k => {
@@ -695,6 +695,9 @@ Object.keys(map).forEach(k => {
 const cur_names = Object.keys(current_contacts_map);
 const num_contacts = req_names.length + pend_names.length + cur_names.length;
 
+const req_jids = req_names.map((name) => `${name.replace(/ /g, '.').toLowerCase()}@${domain}`);
+const cur_jids = cur_names.map((name) => `${name.replace(/ /g, '.').toLowerCase()}@${domain}`);
+
 const groups = {
     'colleagues': 3,
     'friends & acquaintences': 3,
@@ -949,9 +952,9 @@ async function initializedOMEMO(
 }
 
 Object.assign(mock, {
-    bundleIQRequestSent,
     bundleFetched,
     bundleHasBeenPublished,
+    bundleIQRequestSent,
     chatroom_names,
     chatroom_roles,
     checkHeaderToggling,
@@ -961,15 +964,16 @@ Object.assign(mock, {
     createContact,
     createContacts,
     createRequest,
+    cur_jids,
     cur_names,
     current_contacts_map,
     default_muc_features,
     deviceListFetched,
     event,
+    getContactJID,
     groups,
     groups_map,
     initConverse,
-    getContactJID,
     initializedOMEMO,
     num_contacts,
     openAddMUCModal,
@@ -980,8 +984,8 @@ Object.assign(mock, {
     ownDeviceHasBeenPublished,
     pend_names,
     receiveOwnMUCPresence,
-    req_names,
     req_jids,
+    req_names,
     returnMemberLists,
     sendMessage,
     toggleControlBox,

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

@@ -5,6 +5,7 @@ export default class ContactApprovalAlert extends CustomElement {
         };
     };
     contact: any;
+    initialize(): void;
     render(): import("lit-html").TemplateResult<1> | "";
     /**
      * @param {MouseEvent} ev
@@ -14,6 +15,14 @@ export default class ContactApprovalAlert extends CustomElement {
      * @param {MouseEvent} ev
      */
     declineRequest(ev: MouseEvent): Promise<void>;
+    /**
+     * @param {MouseEvent} ev
+     * */
+    showAddContactModal(ev: MouseEvent): void;
+    /**
+     * @param {MouseEvent} ev
+     */
+    close(ev: MouseEvent): Promise<void>;
 }
 import { CustomElement } from 'shared/components/element';
 import { RosterContact } from '@converse/headless';

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

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