瀏覽代碼

feat: Add modal for starting a new chat with an XMPP JID

Fixes #1303
Fixes #2383

test: Add tests for new chat modal functionality
JC Brand (aider) 5 月之前
父節點
當前提交
d7b9077623

+ 4 - 0
CHANGES.md

@@ -10,12 +10,16 @@
 - #1057: Removed the `mobile` view mode. Instead of setting `view_mode` to `mobile`, set it to `fullscreen`.
 - #1174: Show MUC avatars in the rooms list
 - #1195: Add actions to quote and copy messages
+- #1303: Display non-contacts who sent us a message somehow in fullscreen 
 - #1349: XEP-0392 Consistent Color Generation
+- #2383: Add modal to start chats with JIDs not in the roster
 - #2586: Add support for XEP-0402 Bookmarks
 - #2623: Merge MUC join and bookmark, leave and unset autojoin 
 - #2716: Fix issue with chat display when opening via URL
 - #2980: Allow setting an avatar for MUCs
 - #3033: Add the `muc_grouped_by_domain` option to display MUCs on the same domain in collapsible groups
+- #3038: Message to self from other client is ignored
+- #3038: Support showing yourself in the MUC sidebar. Adds new config option `muc_show_self`.
 - #3100: fixed width `.box-flyout` breaks responsive design in embedded, mobile viewport mode.
 - #3155: Some ad-hoc commands not working
 - #3155: Some adhoc commands aren't working

+ 1 - 0
karma.conf.js

@@ -126,6 +126,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/roomslist/tests/grouplists.js", type: 'module' },
       { pattern: "src/plugins/rootview/tests/root.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/add-contact-modal.js", type: 'module' },
+      { 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/roster.js", type: 'module' },

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

@@ -7,6 +7,7 @@ import RosterContactView from './contactview.js';
 import { highlightRosterItem } from './utils.js';
 import "../modal";
 import "./modals/add-contact.js";
+import "./modals/new-chat.js";
 import './rosterview.js';
 
 import 'shared/styles/status.scss';

+ 2 - 2
src/plugins/rosterview/modals/add-contact.js

@@ -49,10 +49,10 @@ export default class AddContactModal extends BaseModal {
      */
     async afterSubmission (_form, jid, name, group) {
         try {
-            await api.roster.add({ jid, name, groups: Array.isArray(group) ? group : [group] });
+            await api.contacts.add({ jid, name, groups: Array.isArray(group) ? group : [group] });
         } catch (e) {
             log.error(e);
-            this.model.set('error', __('Sorry, something went wrong while adding the contact'));
+            this.model.set('error', __('Sorry, something went wrong'));
             return;
         }
         this.model.clear();

+ 69 - 0
src/plugins/rosterview/modals/new-chat.js

@@ -0,0 +1,69 @@
+import { _converse, api, log } from '@converse/headless';
+import BaseModal from 'plugins/modal/modal.js';
+import tplNewChat from './templates/new-chat.js';
+import { __ } from 'i18n';
+
+export default class NewChatModal extends BaseModal {
+    initialize() {
+        super.initialize();
+        this.listenTo(this.model, 'change', () => this.render());
+        this.render();
+        this.addEventListener(
+            'shown.bs.modal',
+            () => /** @type {HTMLInputElement} */ (this.querySelector('input[name="jid"]'))?.focus(),
+            false
+        );
+    }
+
+    renderModal() {
+        return tplNewChat(this);
+    }
+
+    getModalTitle() {
+        return __('Start a new chat');
+    }
+
+    /**
+     * @param {string} jid
+     */
+    validateSubmission(jid) {
+        if (!jid || jid.split('@').filter((s) => !!s).length < 2) {
+            this.model.set('error', __('Please enter a valid XMPP address'));
+            return false;
+        }
+        this.model.set('error', null);
+        return true;
+    }
+
+    /**
+     * @param {HTMLFormElement} _form
+     * @param {string} jid
+     */
+    async afterSubmission(_form, jid) {
+        try {
+            await api.chats.open(jid, {}, true);
+        } catch (e) {
+            log.error(e);
+            this.model.set('error', __('Sorry, something went wrong'));
+            return;
+        }
+        this.model.clear();
+        this.modal.hide();
+    }
+
+    /**
+     * @param {SubmitEvent} ev
+     */
+    async startChatFromForm(ev) {
+        ev.preventDefault();
+        const form = /** @type {HTMLFormElement} */ (ev.target);
+        const data = new FormData(form);
+        const jid = /** @type {string} */ (data.get('jid') || '').trim();
+
+        if (this.validateSubmission(jid)) {
+            this.afterSubmission(form, jid);
+        }
+    }
+}
+
+api.elements.define('converse-new-chat-modal', NewChatModal);

+ 1 - 2
src/plugins/rosterview/modals/templates/add-contact.js

@@ -22,7 +22,7 @@ export default (el) => {
 
     return html`
         <div class="modal-body">
-            <span class="modal-alert"></span>
+            ${error ? html`<div class="alert alert-danger" role="alert">${error}</div>` : ''}
             <form class="converse-form add-xmpp-contact" @submit=${ev => el.addContactFromForm(ev)}>
                 <div class="add-xmpp-contact__jid mb-3">
                     <label class="form-label clearfix" for="jid">${i18n_xmpp_address}:</label>
@@ -67,7 +67,6 @@ export default (el) => {
                         .list=${getGroupsAutoCompleteList()}
                         name="group"></converse-autocomplete>
                 </div>
-                ${error ? html`<div><div style="display: block" class="invalid-feedback">${error}</div></div>` : ''}
                 <button type="submit" class="btn btn-primary">${i18n_add}</button>
             </form>
         </div>`;

+ 22 - 0
src/plugins/rosterview/modals/templates/new-chat.js

@@ -0,0 +1,22 @@
+import { __ } from 'i18n';
+import { html } from 'lit';
+
+/**
+ * @param {import('../new-chat.js').default} el
+ */
+export default (el) => {
+    const i18n_start_chat = __('Start Chat');
+    const i18n_xmpp_address = __('XMPP Address');
+    const error = el.model.get('error');
+
+    return html` <div class="modal-body">
+        ${error ? html`<div class="alert alert-danger" role="alert">${error}</div>` : ''}
+        <form @submit=${/** @param {SubmitEvent} ev */(ev) => el.startChatFromForm(ev)}>
+            <div class="mb-3">
+                <label class="form-label" for="jid">${i18n_xmpp_address}</label>
+                <input type="text" name="jid" class="form-control" required />
+            </div>
+            <button type="submit" class="btn btn-primary">${i18n_start_chat}</button>
+        </form>
+    </div>`;
+};

+ 5 - 0
src/plugins/rosterview/rosterview.js

@@ -51,6 +51,11 @@ export default class RosterView extends CustomElement {
         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);
+    }
+
     /** @param {MouseEvent} [ev] */
     async syncContacts (ev) {
         ev?.preventDefault();

+ 21 - 7
src/plugins/rosterview/templates/roster.js

@@ -1,6 +1,3 @@
-/**
- * @typedef {import('../rosterview').default} RosterView
- */
 import { html } from 'lit';
 import { repeat } from 'lit/directives/repeat.js';
 import { _converse, api, constants } from '@converse/headless';
@@ -18,12 +15,13 @@ import {
 const { CLOSED } = constants;
 
 /**
- * @param {RosterView} el
+ * @param {import('../rosterview').default} el
  */
 export default (el) => {
     const i18n_heading_contacts = __('Contacts');
     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 roster = _converse.state.roster || [];
     const contacts_map = roster.reduce((acc, contact) => populateContactsMap(acc, contact), {});
     const groupnames = Object.keys(contacts_map).filter((contact) => shouldShowGroup(contact, el.model));
@@ -40,7 +38,7 @@ export default (el) => {
             <a
                 href="#"
                 class="dropdown-item add-contact" role="button"
-                @click=${(/** @type {MouseEvent} */ ev) => el.showAddContactModal(ev)}
+                @click="${(/** @type {MouseEvent} */ ev) => el.showAddContactModal(ev)}"
                 title="${i18n_title_add_contact}"
                 data-toggle="modal"
                 data-target="#add-contact-modal"
@@ -51,11 +49,27 @@ export default (el) => {
         `);
     }
 
+    if (api.settings.get('allow_non_roster_messaging')) {
+        btns.push(html`
+            <a
+                href="#"
+                class="dropdown-item new-chat" role="button"
+                @click="${(/** @type {MouseEvent} */ ev) => el.showNewChatModal(ev)}"
+                title="${i18n_title_new_chat}"
+                data-toggle="modal"
+                data-target="#new-chat-modal"
+            >
+                <converse-icon class="fa fa-user-plus" size="1em"></converse-icon>
+                ${i18n_title_new_chat}
+            </a>
+        `);
+    }
+
     if (roster.length > 5) {
         btns.push(html`
             <a href="#"
                class="dropdown-item toggle-filter" role="button"
-               @click=${(/** @type {MouseEvent} */ ev) => el.toggleFilter(ev)}>
+               @click="${(/** @type {MouseEvent} */ ev) => el.toggleFilter(ev)}">
                 <converse-icon size="1em" class="fa fa-filter"></converse-icon>
                 ${is_filter_visible ? i18n_hide_filter : i18n_show_filter}
             </a>
@@ -68,7 +82,7 @@ export default (el) => {
             <a
                 href="#"
                 class="dropdown-item" role="button"
-                @click=${(/** @type {MouseEvent} */ ev) => el.syncContacts(ev)}
+                @click="${(/** @type {MouseEvent} */ ev) => el.syncContacts(ev)}"
                 title="${i18n_title_sync_contacts}"
             >
                 <converse-icon class="fa fa-sync sync-contacts" size="1em"></converse-icon>

+ 1 - 2
src/plugins/rosterview/tests/add-contact-modal.js

@@ -1,5 +1,4 @@
 /*global mock, converse */
-
 const u = converse.env.utils;
 const Strophe = converse.env.Strophe;
 const sizzle = converse.env.sizzle;
@@ -159,7 +158,7 @@ describe("The 'Add Contact' widget", function () {
         input_el.value = 'ambiguous';
         modal.querySelector('button[type="submit"]').click();
 
-        const feedback_el = await u.waitUntil(() => modal.querySelector('.invalid-feedback'));
+        const feedback_el = await u.waitUntil(() => modal.querySelector('.alert-danger'));
         expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
 
         input_el.value = 'existing';

+ 60 - 0
src/plugins/rosterview/tests/new-chat-modal.js

@@ -0,0 +1,60 @@
+/*global mock, converse */
+const { u } = converse.env;
+
+describe('New Chat Modal', function () {
+    it(
+        'should open a new chat with a valid JID',
+        mock.initConverse([], {}, async function (_converse) {
+            const { api } = _converse;
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.openControlBox(_converse);
+
+            const jid = 'romeo@montague.lit';
+            const cbview = _converse.chatboxviews.get('controlbox');
+            const dropdown = await u.waitUntil(() => cbview.querySelector('.dropdown--contacts'));
+            dropdown.querySelector('.new-chat').click();
+
+            const modal = api.modal.get('converse-new-chat-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
+
+            const input = modal.querySelector('input[name="jid"]');
+            input.value = jid;
+
+            const form = modal.querySelector('form');
+            form.querySelector('button[type="submit"]').click();
+
+            await u.waitUntil(() => api.chats.get(jid));
+            expect(modal.model.get('error')).toBe(null);
+        })
+    );
+
+    it(
+        'should return an error for an invalid JID',
+        mock.initConverse([], {}, async function (_converse) {
+            const { api } = _converse;
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.openControlBox(_converse);
+
+            const invalidJid = 'invalid-jid';
+            const cbview = _converse.chatboxviews.get('controlbox');
+            const dropdown = await u.waitUntil(() => cbview.querySelector('.dropdown--contacts'));
+            dropdown.querySelector('.new-chat').click();
+
+            const modal = api.modal.get('converse-new-chat-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
+
+            const input = modal.querySelector('input[name="jid"]');
+            input.value = invalidJid;
+
+            const form = modal.querySelector('form');
+            form.querySelector('button[type="submit"]').click();
+
+            await u.waitUntil(() => api.chats.get(invalidJid));
+
+            const err_msg = 'Please enter a valid XMPP address';
+            expect(modal.model.get('error')).toBeDefined();
+            expect(modal.model.get('error')).toBe(err_msg);
+            expect(modal.querySelector('.alert').textContent).toBe(err_msg);
+        })
+    );
+});

+ 9 - 12
src/plugins/rosterview/tests/protocol.js

@@ -2,7 +2,7 @@
 
 // See: https://xmpp.org/rfcs/rfc3921.html
 
-const { Strophe, stx } = converse.env;
+const { u, $iq, $pres, sizzle, Strophe, stx } = converse.env;
 
 describe("The Protocol", function () {
 
@@ -40,7 +40,6 @@ describe("The Protocol", function () {
         it("Subscribe to contact, contact accepts and subscribes back",
                 mock.initConverse([], { roster_groups: false }, async function (_converse) {
 
-            const { u, $iq, $pres, sizzle, Strophe } = converse.env;
             let stanza;
             await mock.waitForRoster(_converse, 'current', 0);
             await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
@@ -51,7 +50,6 @@ describe("The Protocol", function () {
             mock.openControlBox(_converse);
             const cbview = _converse.chatboxviews.get('controlbox');
 
-            spyOn(_converse.roster, "addContact").and.callThrough();
             spyOn(_converse.roster, "sendContactAddIQ").and.callThrough();
             spyOn(_converse.api.vcard, "get").and.callThrough();
 
@@ -74,7 +72,6 @@ describe("The Protocol", function () {
              * subscription, the user's client SHOULD perform a "roster set"
              * for the new roster item.
              */
-            expect(_converse.roster.addContact).toHaveBeenCalled();
 
             /* The request consists of sending an IQ
              * stanza of type='set' containing a <query/> element qualified by
@@ -100,14 +97,14 @@ describe("The Protocol", function () {
             const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
             const roster_set_stanza = IQ_stanzas.filter(s => sizzle('query[xmlns="jabber:iq:roster"]', s)).pop();
 
-            expect(Strophe.serialize(roster_set_stanza)).toBe(
-                `<iq id="${roster_set_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                    `<query xmlns="jabber:iq:roster">`+
-                        `<item jid="contact@example.org" name="Chris Contact">`+
-                            `<group>My Buddies</group>`+
-                        `</item>`+
-                    `</query>`+
-                `</iq>`
+            expect(roster_set_stanza).toEqualStanza(
+                stx`<iq id="${roster_set_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">
+                    <query xmlns="jabber:iq:roster">
+                        <item jid="contact@example.org" name="Chris Contact">
+                            <group>My Buddies</group>
+                        </item>
+                    </query>
+                </iq>`
             );
 
             const sent_stanzas = [];

+ 15 - 3
src/types/plugins/rosterview/modals/add-contact.d.ts

@@ -1,9 +1,21 @@
 export default class AddContactModal extends BaseModal {
     renderModal(): import("lit").TemplateResult<1>;
     getModalTitle(): any;
-    validateSubmission(jid: any): boolean;
-    afterSubmission(_form: any, jid: any, name: any, group: any): void;
-    addContactFromForm(ev: any): Promise<void>;
+    /**
+     * @param {string} jid
+     */
+    validateSubmission(jid: string): boolean;
+    /**
+     * @param {HTMLFormElement} _form
+     * @param {string} jid
+     * @param {string} name
+     * @param {FormDataEntryValue} group
+     */
+    afterSubmission(_form: HTMLFormElement, jid: string, name: string, group: FormDataEntryValue): Promise<void>;
+    /**
+     * @param {Event} ev
+     */
+    addContactFromForm(ev: Event): Promise<void>;
 }
 import BaseModal from 'plugins/modal/modal.js';
 //# sourceMappingURL=add-contact.d.ts.map

+ 19 - 0
src/types/plugins/rosterview/modals/new-chat.d.ts

@@ -0,0 +1,19 @@
+export default class NewChatModal extends BaseModal {
+    renderModal(): import("lit").TemplateResult<1>;
+    getModalTitle(): any;
+    /**
+     * @param {string} jid
+     */
+    validateSubmission(jid: string): boolean;
+    /**
+     * @param {HTMLFormElement} _form
+     * @param {string} jid
+     */
+    afterSubmission(_form: HTMLFormElement, jid: string): Promise<void>;
+    /**
+     * @param {SubmitEvent} ev
+     */
+    startChatFromForm(ev: SubmitEvent): Promise<void>;
+}
+import BaseModal from 'plugins/modal/modal.js';
+//# sourceMappingURL=new-chat.d.ts.map

+ 1 - 1
src/types/plugins/rosterview/modals/templates/add-contact.d.ts

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

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

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

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

@@ -9,6 +9,8 @@ export default class RosterView extends CustomElement {
     render(): import("lit").TemplateResult<1>;
     /** @param {MouseEvent} ev */
     showAddContactModal(ev: MouseEvent): void;
+    /** @param {MouseEvent} ev */
+    showNewChatModal(ev: MouseEvent): void;
     /** @param {MouseEvent} [ev] */
     syncContacts(ev?: MouseEvent): Promise<void>;
     syncing_contacts: boolean;

+ 1 - 2
src/types/plugins/rosterview/templates/roster.d.ts

@@ -1,4 +1,3 @@
-declare function _default(el: RosterView): import("lit").TemplateResult<1>;
+declare function _default(el: import("../rosterview").default): import("lit").TemplateResult<1>;
 export default _default;
-export type RosterView = import("../rosterview").default;
 //# sourceMappingURL=roster.d.ts.map