Explorar el Código

Refactor the roster plugin

I want to make it possible to add contacts with no presence
subscription, so technically contacts that aren't in the roster, so that
we can see them in the controlbox sidebar when we have chats open with
them.
JC Brand hace 7 meses
padre
commit
aafe85a58d
Se han modificado 37 ficheros con 461 adiciones y 248 borrados
  1. 1 0
      CHANGES.md
  2. 20 17
      src/headless/plugins/roster/api.js
  3. 0 5
      src/headless/plugins/roster/contact.js
  4. 81 68
      src/headless/plugins/roster/contacts.js
  5. 1 0
      src/headless/plugins/roster/plugin.js
  6. 8 0
      src/headless/plugins/roster/types.ts
  7. 13 8
      src/headless/shared/model-with-contact.js
  8. 16 8
      src/headless/tests/converse.js
  9. 2 2
      src/headless/types/plugins/disco/api.d.ts
  10. 12 12
      src/headless/types/plugins/roster/api.d.ts
  11. 0 5
      src/headless/types/plugins/roster/contact.d.ts
  12. 39 32
      src/headless/types/plugins/roster/contacts.d.ts
  13. 9 0
      src/headless/types/plugins/roster/types.d.ts
  14. 24 8
      src/headless/utils/jid.js
  15. 1 1
      src/plugins/chatview/tests/messages.js
  16. 3 2
      src/plugins/controlbox/styles/_controlbox.scss
  17. 7 5
      src/plugins/modal/styles/_modal.scss
  18. 46 22
      src/plugins/rosterview/contactview.js
  19. 1 4
      src/plugins/rosterview/modals/add-contact.js
  20. 1 1
      src/plugins/rosterview/styles/roster.scss
  21. 3 0
      src/plugins/rosterview/templates/group.js
  22. 31 17
      src/plugins/rosterview/templates/requesting_contact.js
  23. 1 1
      src/plugins/rosterview/templates/roster_item.js
  24. 51 0
      src/plugins/rosterview/templates/unsaved_contact.js
  25. 3 3
      src/plugins/rosterview/tests/add-contact-modal.js
  26. 2 2
      src/plugins/rosterview/tests/protocol.js
  27. 5 0
      src/plugins/rosterview/types.ts
  28. 22 17
      src/plugins/rosterview/utils.js
  29. 2 2
      src/types/plugins/chatview/bottom-panel.d.ts
  30. 17 0
      src/types/plugins/muc-views/occupant-bottom-panel.d.ts
  31. 3 0
      src/types/plugins/muc-views/templates/occupant-bottom-panel.d.ts
  32. 20 4
      src/types/plugins/rosterview/contactview.d.ts
  33. 1 1
      src/types/plugins/rosterview/templates/requesting_contact.d.ts
  34. 1 0
      src/types/plugins/rosterview/templates/roster_item.d.ts
  35. 3 0
      src/types/plugins/rosterview/templates/unsaved_contact.d.ts
  36. 5 0
      src/types/plugins/rosterview/types.d.ts
  37. 6 1
      src/types/plugins/rosterview/utils.d.ts

+ 1 - 0
CHANGES.md

@@ -58,6 +58,7 @@
 - `api.modal.create` no longer takes a class, instead it takes the name of a custom DOM element.
 - `getAssignableRoles` and `getAssignableAffiliations` are no longer on the `_converse` object, but on the Occupant instance.
 - Removed the `chatBoxFocused` and `chatBoxBlurred` events.
+- Changed the signature of the `api.contacts.add` API method.
 
 ## 10.1.7 (2024-03-15)
 

+ 20 - 17
src/headless/plugins/roster/api.js

@@ -1,3 +1,4 @@
+import { isValidJID } from '../../utils/jid.js';
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
@@ -8,16 +9,17 @@ export default {
     /**
      * @namespace _converse.api.contacts
      * @memberOf _converse.api
+     *
+     * @typedef {import('./contact').default} RosterContact
      */
     contacts: {
         /**
          * This method is used to retrieve roster contacts.
          *
          * @method _converse.api.contacts.get
-         * @params {(string[]|string)} jid|jids The JID or JIDs of
-         *      the contacts to be returned.
+         * @param {(string[]|string)} jids The JID or JIDs of the contacts to be returned.
          * @returns {promise} Promise which resolves with the
-         *  _converse.RosterContact (or an array of them) representing the contact.
+         *  {@link RosterContact} (or an array of them) representing the contact.
          *
          * @example
          * // Fetch a single contact
@@ -45,32 +47,33 @@ export default {
         async get (jids) {
             await api.waitUntil('rosterContactsFetched');
             const { roster } = _converse.state;
-            const _getter = jid => roster.get(Strophe.getBareJidFromJid(jid));
+            const _getter = /** @param {string} jid */(jid) => roster.get(Strophe.getBareJidFromJid(jid));
             if (jids === undefined) {
                 jids = roster.pluck('jid');
             } else if (typeof jids === 'string') {
                 return _getter(jids);
             }
-            return jids.map(_getter);
+            return /** @type {string[]} */(jids).map(_getter);
         },
 
         /**
          * Add a contact.
-         *
-         * @method _converse.api.contacts.add
-         * @param { string } jid The JID of the contact to be added
-         * @param { string } [name] A custom name to show the user by in the roster
-         * @example
-         *     _converse.api.contacts.add('buddy@example.com')
+         * @param {import('./types').RosterContactAttributes} attributes
+         * @param {boolean} [persist=true] - Whether the contact should be persisted to the user's roster.
+         * @param {boolean} [subscribe=true] - Whether we should subscribe to the contacts presence updates.
+         * @param {string} [message=''] - An optional message to include with the presence subscription
+         * @param {boolean} subscribe - Whether a presense subscription should
+         *      be sent out to the contact being added.
+         * @returns {Promise<RosterContact>}
          * @example
-         *     _converse.api.contacts.add('buddy@example.com', 'Buddy')
+         *      api.contacts.add({ jid: 'buddy@example.com', groups: ['Buddies'] })
          */
-        async add (jid, name) {
+        async add (attributes, persist=true, subscribe=true, message='') {
+            if (!isValidJID(attributes?.jid)) throw new Error('api.contacts.add: Valid JID required');
+
             await api.waitUntil('rosterContactsFetched');
-            if (typeof jid !== 'string' || !jid.includes('@')) {
-                throw new TypeError('contacts.add: invalid jid');
-            }
-            return _converse.state.roster.addAndSubscribe(jid, name);
+            const { roster } = _converse.state;
+            return roster.addContact(attributes, persist, subscribe, message);
         }
     }
 }

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

@@ -96,7 +96,6 @@ class RosterContact extends ColorAwareModel(Model) {
 
     /**
      * Send a presence subscription request to this roster contact
-     * @method RosterContacts#subscribe
      * @param {string} message - An optional message to explain the
      *      reason for the subscription request.
      */
@@ -111,7 +110,6 @@ class RosterContact extends ColorAwareModel(Model) {
      * the user SHOULD acknowledge receipt of that subscription
      * state notification by sending a presence stanza of type
      * "subscribe" to the contact
-     * @method RosterContacts#ackSubscribe
      */
     ackSubscribe () {
         api.send($pres({
@@ -136,7 +134,6 @@ class RosterContact extends ColorAwareModel(Model) {
 
     /**
      * Unauthorize this contact's presence subscription
-     * @method RosterContacts#unauthorize
      * @param {string} message - Optional message to send to the person being unauthorized
      */
     unauthorize (message) {
@@ -146,7 +143,6 @@ class RosterContact extends ColorAwareModel(Model) {
 
     /**
      * Authorize presence subscription
-     * @method RosterContacts#authorize
      * @param {string} message - Optional message to send to the person being authorized
      */
     authorize (message) {
@@ -160,7 +156,6 @@ class RosterContact extends ColorAwareModel(Model) {
 
     /**
      * Instruct the XMPP server to remove this contact from our roster
-     * @method RosterContacts#removeFromRoster
      * @returns {Promise}
      */
     removeFromRoster () {

+ 81 - 68
src/headless/plugins/roster/contacts.js

@@ -39,7 +39,7 @@ class RosterContacts extends Collection {
     registerRosterHandler () {
         // Register a handler for roster IQ "set" stanzas, which update
         // roster contacts.
-        api.connection.get().addHandler((iq) => {
+        api.connection.get().addHandler(/** @param {Element} iq */(iq) => {
             _converse.state.roster.onRosterPush(iq);
             return true;
         }, Strophe.NS.ROSTER, 'iq', "set");
@@ -52,7 +52,7 @@ class RosterContacts extends Collection {
     registerRosterXHandler () {
         let t = 0;
         const connection = api.connection.get();
-        connection.addHandler((msg) => {
+        connection.addHandler(/** @param {Element} msg */(msg) => {
                 setTimeout(() => {
                     const { roster } = _converse.state;
                     api.connection.get().flush();
@@ -103,109 +103,112 @@ class RosterContacts extends Collection {
         }
     }
 
-    // eslint-disable-next-line class-methods-use-this
+    /**
+     * @param {Element} msg
+     */
     subscribeToSuggestedItems (msg) {
         const { xmppstatus } = _converse.state;
         Array.from(msg.querySelectorAll('item')).forEach((item) => {
             if (item.getAttribute('action') === 'add') {
-                _converse.state.roster.addAndSubscribe(
-                    item.getAttribute('jid'),
-                    xmppstatus.getNickname() || xmppstatus.getFullname()
+                _converse.state.roster.addContact(
+                    {
+                        jid: item.getAttribute('jid'),
+                        name: xmppstatus.getNickname() || xmppstatus.getFullname(),
+                    },
                 );
             }
         });
         return true;
     }
 
-    // eslint-disable-next-line class-methods-use-this
-    isSelf (jid) {
-        return u.isSameBareJID(jid, api.connection.get().jid);
-    }
-
     /**
-     * Add a roster contact and then once we have confirmation from
-     * the XMPP server we subscribe to that contact's presence updates.
-     * @method _converse.RosterContacts#addAndSubscribe
-     * @param { String } jid - The Jabber ID of the user being added and subscribed to.
-     * @param { String } name - The name of that user
-     * @param { Array<String> } groups - Any roster groups the user might belong to
-     * @param { String } message - An optional message to explain the reason for the subscription request.
-     * @param { Object } attributes - Any additional attributes to be stored on the user's model.
+     * @param {string} jid
      */
-    async addAndSubscribe (jid, name, groups, message, attributes) {
-        const contact = await this.addContactToRoster(jid, name, groups, attributes);
-        if (contact instanceof _converse.exports.RosterContact) {
-            contact.subscribe(message);
-        }
+    isSelf (jid) {
+        return u.isSameBareJID(jid, api.connection.get().jid);
     }
 
     /**
      * Send an IQ stanza to the XMPP server to add a new roster contact.
-     * @method _converse.RosterContacts#sendContactAddIQ
-     * @param { String } jid - The Jabber ID of the user being added
-     * @param { String } name - The name of that user
-     * @param { Array<String> } groups - Any roster groups the user might belong to
+     * @param {import('./types.ts').RosterContactAttributes} attributes
      */
-    // eslint-disable-next-line class-methods-use-this
-    sendContactAddIQ (jid, name, groups) {
-        name = name ? name : null;
+    sendContactAddIQ (attributes) {
+        const { jid, groups } = attributes;
+        const name = attributes.name ? attributes.name : null;
         const iq = $iq({ 'type': 'set' }).c('query', { 'xmlns': Strophe.NS.ROSTER }).c('item', { jid, name });
-        groups.forEach((g) => iq.c('group').t(g).up());
+        groups?.forEach((g) => iq.c('group').t(g).up());
         return api.sendIQ(iq);
     }
 
     /**
-     * Adds a RosterContact instance to _converse.roster and
-     * registers the contact on the XMPP server.
-     * Returns a promise which is resolved once the XMPP server has responded.
-     * @method _converse.RosterContacts#addContactToRoster
-     * @param {String} jid - The Jabber ID of the user being added and subscribed to.
-     * @param {String} name - The name of that user
-     * @param {Array<String>} groups - Any roster groups the user might belong to
-     * @param {Object} attributes - Any additional attributes to be stored on the user's model.
+     * Adds a {@link RosterContact} instance to {@link RosterContacts} and
+     * optionally (if subscribe=true) subscribe to the contact's presence
+     * updates which also adds the contact to the roster on the XMPP server.
+     * @param {import('./types.ts').RosterContactAttributes} attributes
+     * @param {boolean} [persist=true] - Whether the contact should be persisted to the user's roster.
+     * @param {boolean} [subscribe=true] - Whether we should subscribe to the contacts presence updates.
+     * @param {string} [message=''] - An optional message to include with the presence subscription
+     * @returns {Promise<RosterContact>}
      */
-    async addContactToRoster (jid, name, groups, attributes) {
+    async addContact (attributes, persist=true, subscribe=true, message='') {
+        const { jid, name } = attributes ?? {};
+        if (!jid || !u.isValidJID(jid)) throw new Error('Invalid JID provided to addContact');
+
         await api.waitUntil('rosterContactsFetched');
-        groups = groups || [];
-        try {
-            await this.sendContactAddIQ(jid, name, groups);
-        } catch (e) {
-            const { __ } = _converse;
-            log.error(e);
-            alert(__('Sorry, there was an error while trying to add %1$s as a contact.', name || jid));
-            return e;
+
+        if (persist) {
+            try {
+                await this.sendContactAddIQ(attributes);
+            } catch (e) {
+                log.error(e);
+                const { __ } = _converse;
+                alert(__('Sorry, an error occurred while trying to add %1$s as a contact.', name || jid));
+                throw e;
+            }
         }
-        return this.create(
-            Object.assign(
-                {
-                    'ask': undefined,
-                    'nickname': name,
-                    groups,
-                    jid,
-                    'requesting': false,
-                    'subscription': 'none',
+
+        const contact = await this.create({
+                ...{
+                    ask: undefined,
+                    nickname: name,
+                    groups: [],
+                    requesting: false,
+                    subscription: 'none',
                 },
-                attributes
-            ),
+                ...attributes,
+            },
             { 'sort': false }
         );
+
+        if (subscribe) contact.subscribe(message);
+
+        return contact;
     }
 
     /**
-     * @param {String} bare_jid
+     * @param {string} bare_jid
      * @param {Element} presence
+     * @param {string} [auth_msg=''] - Optional message to be included in the
+     *   authorization of the contacts subscription request.
+     * @param {string} [sub_msg=''] - Optional message to be included in our
+     *   reciprocal subscription request.
      */
-    async subscribeBack (bare_jid, presence) {
+    async subscribeBack (bare_jid, presence, auth_msg='', sub_msg='') {
         const contact = this.get(bare_jid);
         const { RosterContact } = _converse.exports;
         if (contact instanceof RosterContact) {
             contact.authorize().subscribe();
         } else {
             // Can happen when a subscription is retried or roster was deleted
-            const nickname = sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop()?.textContent || null;
-            const contact = await this.addContactToRoster(bare_jid, nickname, [], { 'subscription': 'from' });
+            const nickname = sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop()?.textContent || undefined;
+            const contact = await this.addContact({
+                jid: bare_jid,
+                name: nickname,
+                groups: [],
+                subscription: 'from'
+            });
             if (contact instanceof RosterContact) {
-                contact.authorize().subscribe();
+                contact.authorize(auth_msg).subscribe(sub_msg);
             }
         }
     }
@@ -213,7 +216,6 @@ class RosterContacts extends Collection {
     /**
      * Handle roster updates from the XMPP server.
      * See: https://xmpp.org/rfcs/rfc6121.html#roster-syntax-actions-push
-     * @method _converse.RosterContacts#onRosterPush
      * @param { Element } iq - The IQ stanza received from the XMPP server.
      */
     onRosterPush (iq) {
@@ -283,7 +285,7 @@ class RosterContacts extends Collection {
                 if (!this.data.get('version') && this.models.length) {
                     // We're getting the full roster, so remove all cached
                     // contacts that aren't included in it.
-                    const jids = items.map((item) => item.getAttribute('jid'));
+                    const jids = items.map(/** @param {Element} item */(item) => item.getAttribute('jid'));
                     this.forEach((m) => !m.get('requesting') && !jids.includes(m.get('jid')) && m.destroy());
                 }
                 items.forEach((item) => this.updateContact(item));
@@ -336,6 +338,9 @@ class RosterContacts extends Collection {
         }
     }
 
+    /**
+     * @param {Element} presence
+     */
     createRequestingContact (presence) {
         const bare_jid = Strophe.getBareJidFromJid(presence.getAttribute('from'));
         const nickname = sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop()?.textContent || null;
@@ -355,6 +360,9 @@ class RosterContacts extends Collection {
         api.trigger('contactRequest', this.create(user_data));
     }
 
+    /**
+     * @param {Element} presence
+     */
     handleIncomingSubscription (presence) {
         const jid = presence.getAttribute('from'),
             bare_jid = Strophe.getBareJidFromJid(jid),
@@ -383,7 +391,9 @@ class RosterContacts extends Collection {
         }
     }
 
-    // eslint-disable-next-line class-methods-use-this
+    /**
+     * @param {Element} presence
+     */
     handleOwnPresence (presence) {
         const jid = presence.getAttribute('from');
         const resource = Strophe.getResourceFromJid(jid);
@@ -422,6 +432,9 @@ class RosterContacts extends Collection {
         }
     }
 
+    /**
+     * @param {Element} presence
+     */
     presenceHandler (presence) {
         const presence_type = presence.getAttribute('type');
         if (presence_type === 'error') return true;

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

@@ -38,6 +38,7 @@ converse.plugins.add('converse-roster', {
 
         const { __ } = _converse;
         const labels = {
+            HEADER_UNSAVED_CONTACTS: __('Unsaved contacts'),
             HEADER_CURRENT_CONTACTS: __('My contacts'),
             HEADER_PENDING_CONTACTS: __('Pending contacts'),
             HEADER_REQUESTING_CONTACTS: __('Contact requests'),

+ 8 - 0
src/headless/plugins/roster/types.ts

@@ -0,0 +1,8 @@
+export type RosterContactAttributes = {
+    jid: string;
+    subscription: ('none'|'to'|'from'|'both');
+    ask?: 'subscribe'; // The Jabber ID of the user being added and subscribed to
+    name?: string; // The name of that user
+    groups?: string[]; // Any roster groups the user might belong to
+    requesting?: boolean;
+}

+ 13 - 8
src/headless/shared/model-with-contact.js

@@ -38,17 +38,22 @@ export default function ModelWithContact(BaseModel) {
         async setModelContact(jid) {
             if (this.contact?.get('jid') === jid) return;
 
-            if (Strophe.getBareJidFromJid(jid) === _converse.session.get('bare_jid')) {
-                this.contact = _converse.state.xmppstatus;
+            const { session, state } = _converse;
+
+            let contact;
+            if (Strophe.getBareJidFromJid(jid) === session.get('bare_jid')) {
+                contact = state.xmppstatus;
             } else {
-                const contact = await api.contacts.get(jid);
-                if (contact) {
-                    this.contact = contact;
-                    this.set('nickname', contact.get('nickname'));
-                }
+                contact = await api.contacts.get(jid) || await api.contacts.add({
+                    jid,
+                    subscription: 'none',
+                }, false, false);
             }
 
-            if (this.contact) {
+            if (contact) {
+                this.contact = contact;
+                this.set('nickname', contact.get('nickname'));
+
                 this.listenTo(this.contact, 'change', (changed) => {
                     if (changed.nickname) {
                         this.set('nickname', changed.nickname);

+ 16 - 8
src/headless/tests/converse.js

@@ -193,23 +193,31 @@ describe("Converse", function() {
         it("has a method 'add' with which contacts can be added",
                 mock.initConverse(['rosterInitialized'], {}, async (_converse) => {
 
+            const { api } = _converse;
+
             await mock.waitForRoster(_converse, 'current', 0);
             try {
-                await _converse.api.contacts.add();
+                await api.contacts.add();
                 throw new Error('Call should have failed');
             } catch (e) {
-                expect(e.message).toBe('contacts.add: invalid jid');
-
+                expect(e.message).toBe('api.contacts.add: Valid JID required');
             }
             try {
-                await _converse.api.contacts.add("invalid jid");
+                await api.contacts.add({ jid: "invalid jid" });
                 throw new Error('Call should have failed');
             } catch (e) {
-                expect(e.message).toBe('contacts.add: invalid jid');
+                expect(e.message).toBe('api.contacts.add: Valid JID required');
             }
-            spyOn(_converse.roster, 'addAndSubscribe');
-            await _converse.api.contacts.add("newcontact@example.org");
-            expect(_converse.roster.addAndSubscribe).toHaveBeenCalled();
+
+            // Create a contact that doesn't get persisted to the
+            // roster, to avoid having to mock stanzas.
+            await api.contacts.add({ jid: "newcontact@example.org" }, false, false);
+            const contacts = await api.contacts.get()
+            expect(contacts.length).toBe(1);
+            expect(contacts[0].get('jid')).toBe("newcontact@example.org");
+            expect(contacts[0].get('subscription')).toBe("none");
+            expect(contacts[0].get('ask')).toBeUndefined();
+            expect(contacts[0].get('groups').length).toBe(0);
         }));
     });
 

+ 2 - 2
src/headless/types/plugins/disco/api.d.ts

@@ -119,13 +119,13 @@ declare namespace _default {
                 name: string;
             }, options?: {
                 ignore_cache?: boolean;
-            }): false | import("@converse/skeletor").Model | import("@converse/skeletor/src/types/collection.js").Attributes | (Promise<any> & {
+            }): false | import("@converse/skeletor").Model | (Promise<any> & {
                 isResolved: boolean;
                 isPending: boolean;
                 isRejected: boolean;
                 resolve: Function;
                 reject: Function;
-            });
+            }) | import("@converse/skeletor/src/types/collection.js").Attributes;
         }
         export namespace features_1 {
             /**

+ 12 - 12
src/headless/types/plugins/roster/api.d.ts

@@ -4,10 +4,9 @@ declare namespace _default {
          * This method is used to retrieve roster contacts.
          *
          * @method _converse.api.contacts.get
-         * @params {(string[]|string)} jid|jids The JID or JIDs of
-         *      the contacts to be returned.
+         * @param {(string[]|string)} jids The JID or JIDs of the contacts to be returned.
          * @returns {promise} Promise which resolves with the
-         *  _converse.RosterContact (or an array of them) representing the contact.
+         *  {@link RosterContact} (or an array of them) representing the contact.
          *
          * @example
          * // Fetch a single contact
@@ -32,19 +31,20 @@ declare namespace _default {
          *     // ...
          * });
          */
-        function get(jids: any): Promise<any>;
+        function get(jids: (string[] | string)): Promise<any>;
         /**
          * Add a contact.
-         *
-         * @method _converse.api.contacts.add
-         * @param { string } jid The JID of the contact to be added
-         * @param { string } [name] A custom name to show the user by in the roster
-         * @example
-         *     _converse.api.contacts.add('buddy@example.com')
+         * @param {import('./types').RosterContactAttributes} attributes
+         * @param {boolean} [persist=true] - Whether the contact should be persisted to the user's roster.
+         * @param {boolean} [subscribe=true] - Whether we should subscribe to the contacts presence updates.
+         * @param {string} [message=''] - An optional message to include with the presence subscription
+         * @param {boolean} subscribe - Whether a presense subscription should
+         *      be sent out to the contact being added.
+         * @returns {Promise<RosterContact>}
          * @example
-         *     _converse.api.contacts.add('buddy@example.com', 'Buddy')
+         *      api.contacts.add({ jid: 'buddy@example.com', groups: ['Buddies'] })
          */
-        function add(jid: string, name?: string): Promise<any>;
+        function add(attributes: import("./types").RosterContactAttributes, persist?: boolean, subscribe?: boolean, message?: string): Promise<import("./contact").default>;
     }
 }
 export default _default;

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

@@ -94,7 +94,6 @@ declare class RosterContact extends RosterContact_base {
     getFullname(): any;
     /**
      * Send a presence subscription request to this roster contact
-     * @method RosterContacts#subscribe
      * @param {string} message - An optional message to explain the
      *      reason for the subscription request.
      */
@@ -104,7 +103,6 @@ declare class RosterContact extends RosterContact_base {
      * the user SHOULD acknowledge receipt of that subscription
      * state notification by sending a presence stanza of type
      * "subscribe" to the contact
-     * @method RosterContacts#ackSubscribe
      */
     ackSubscribe(): void;
     /**
@@ -118,19 +116,16 @@ declare class RosterContact extends RosterContact_base {
     ackUnsubscribe(): void;
     /**
      * Unauthorize this contact's presence subscription
-     * @method RosterContacts#unauthorize
      * @param {string} message - Optional message to send to the person being unauthorized
      */
     unauthorize(message: string): this;
     /**
      * Authorize presence subscription
-     * @method RosterContacts#authorize
      * @param {string} message - Optional message to send to the person being authorized
      */
     authorize(message: string): this;
     /**
      * Instruct the XMPP server to remove this contact from our roster
-     * @method RosterContacts#removeFromRoster
      * @returns {Promise}
      */
     removeFromRoster(): Promise<any>;

+ 39 - 32
src/headless/types/plugins/roster/contacts.d.ts

@@ -21,47 +21,42 @@ declare class RosterContacts extends Collection {
      * @returns {promise} Promise which resolves once the contacts have been fetched.
      */
     fetchRosterContacts(): Promise<any>;
-    subscribeToSuggestedItems(msg: any): boolean;
-    isSelf(jid: any): any;
     /**
-     * Add a roster contact and then once we have confirmation from
-     * the XMPP server we subscribe to that contact's presence updates.
-     * @method _converse.RosterContacts#addAndSubscribe
-     * @param { String } jid - The Jabber ID of the user being added and subscribed to.
-     * @param { String } name - The name of that user
-     * @param { Array<String> } groups - Any roster groups the user might belong to
-     * @param { String } message - An optional message to explain the reason for the subscription request.
-     * @param { Object } attributes - Any additional attributes to be stored on the user's model.
+     * @param {Element} msg
      */
-    addAndSubscribe(jid: string, name: string, groups: Array<string>, message: string, attributes: any): Promise<void>;
+    subscribeToSuggestedItems(msg: Element): boolean;
+    /**
+     * @param {string} jid
+     */
+    isSelf(jid: string): any;
     /**
      * Send an IQ stanza to the XMPP server to add a new roster contact.
-     * @method _converse.RosterContacts#sendContactAddIQ
-     * @param { String } jid - The Jabber ID of the user being added
-     * @param { String } name - The name of that user
-     * @param { Array<String> } groups - Any roster groups the user might belong to
+     * @param {import('./types').RosterContactAttributes} attributes
      */
-    sendContactAddIQ(jid: string, name: string, groups: Array<string>): any;
+    sendContactAddIQ(attributes: import("./types").RosterContactAttributes): any;
     /**
-     * Adds a RosterContact instance to _converse.roster and
-     * registers the contact on the XMPP server.
-     * Returns a promise which is resolved once the XMPP server has responded.
-     * @method _converse.RosterContacts#addContactToRoster
-     * @param {String} jid - The Jabber ID of the user being added and subscribed to.
-     * @param {String} name - The name of that user
-     * @param {Array<String>} groups - Any roster groups the user might belong to
-     * @param {Object} attributes - Any additional attributes to be stored on the user's model.
+     * Adds a {@link RosterContact} instance to {@link RosterContacts} and
+     * optionally (if subscribe=true) subscribe to the contact's presence
+     * updates which also adds the contact to the roster on the XMPP server.
+     * @param {import('./types').RosterContactAttributes} attributes
+     * @param {boolean} [persist=true] - Whether the contact should be persisted to the user's roster.
+     * @param {boolean} [subscribe=true] - Whether we should subscribe to the contacts presence updates.
+     * @param {string} [message=''] - An optional message to include with the presence subscription
+     * @returns {Promise<RosterContact>}
      */
-    addContactToRoster(jid: string, name: string, groups: Array<string>, attributes: any): Promise<any>;
+    addContact(attributes: import("./types").RosterContactAttributes, persist?: boolean, subscribe?: boolean, message?: string): Promise<RosterContact>;
     /**
-     * @param {String} bare_jid
+     * @param {string} bare_jid
      * @param {Element} presence
+     * @param {string} [auth_msg=''] - Optional message to be included in the
+     *   authorization of the contacts subscription request.
+     * @param {string} [sub_msg=''] - Optional message to be included in our
+     *   reciprocal subscription request.
      */
-    subscribeBack(bare_jid: string, presence: Element): Promise<void>;
+    subscribeBack(bare_jid: string, presence: Element, auth_msg?: string, sub_msg?: string): Promise<void>;
     /**
      * Handle roster updates from the XMPP server.
      * See: https://xmpp.org/rfcs/rfc6121.html#roster-syntax-actions-push
-     * @method _converse.RosterContacts#onRosterPush
      * @param { Element } iq - The IQ stanza received from the XMPP server.
      */
     onRosterPush(iq: Element): void;
@@ -78,10 +73,22 @@ declare class RosterContacts extends Collection {
      * @param { Element } item
      */
     updateContact(item: Element): any;
-    createRequestingContact(presence: any): void;
-    handleIncomingSubscription(presence: any): void;
-    handleOwnPresence(presence: any): void;
-    presenceHandler(presence: any): true | void;
+    /**
+     * @param {Element} presence
+     */
+    createRequestingContact(presence: Element): void;
+    /**
+     * @param {Element} presence
+     */
+    handleIncomingSubscription(presence: Element): void;
+    /**
+     * @param {Element} presence
+     */
+    handleOwnPresence(presence: Element): void;
+    /**
+     * @param {Element} presence
+     */
+    presenceHandler(presence: Element): true | void;
 }
 import { Collection } from "@converse/skeletor";
 import RosterContact from './contact.js';

+ 9 - 0
src/headless/types/plugins/roster/types.d.ts

@@ -0,0 +1,9 @@
+export type RosterContactAttributes = {
+    jid: string;
+    subscription: ('none' | 'to' | 'from' | 'both');
+    ask?: 'subscribe';
+    name?: string;
+    groups?: string[];
+    requesting?: boolean;
+};
+//# sourceMappingURL=types.d.ts.map

+ 24 - 8
src/headless/utils/jid.js

@@ -1,24 +1,42 @@
 import { Strophe } from 'strophe.js';
 
-export function isValidJID (jid) {
+/**
+ * @param {string|null} [jid]
+ * @returns {boolean}
+ */
+export function isValidJID(jid) {
     if (typeof jid === 'string') {
         return jid.split('@').filter((s) => !!s).length === 2 && !jid.startsWith('@') && !jid.endsWith('@');
     }
     return false;
 }
 
-export function isValidMUCJID (jid) {
+/**
+ * @param {string} jid
+ * @returns {boolean}
+ */
+export function isValidMUCJID(jid) {
     return !jid.startsWith('@') && !jid.endsWith('@');
 }
 
-export function isSameBareJID (jid1, jid2) {
+/**
+ * @param {string} jid1
+ * @param {string} jid2
+ * @returns {boolean}
+ */
+export function isSameBareJID(jid1, jid2) {
     if (typeof jid1 !== 'string' || typeof jid2 !== 'string') {
         return false;
     }
     return Strophe.getBareJidFromJid(jid1).toLowerCase() === Strophe.getBareJidFromJid(jid2).toLowerCase();
 }
 
-export function isSameDomain (jid1, jid2) {
+/**
+ * @param {string} jid1
+ * @param {string} jid2
+ * @returns {boolean}
+ */
+export function isSameDomain(jid1, jid2) {
     if (typeof jid1 !== 'string' || typeof jid2 !== 'string') {
         return false;
     }
@@ -28,8 +46,6 @@ export function isSameDomain (jid1, jid2) {
 /**
  * @param {string} jid
  */
-export function getJIDFromURI (jid) {
-    return jid.startsWith('xmpp:') && jid.endsWith('?join')
-        ? jid.replace(/^xmpp:/, '').replace(/\?join$/, '')
-        : jid;
+export function getJIDFromURI(jid) {
+    return jid.startsWith('xmpp:') && jid.endsWith('?join') ? jid.replace(/^xmpp:/, '').replace(/\?join$/, '') : jid;
 }

+ 1 - 1
src/plugins/chatview/tests/messages.js

@@ -997,7 +997,7 @@ describe("A Chat Message", function () {
 
         describe("who is not on the roster", function () {
 
-            fit("will open a chatbox and be displayed inside it if allow_non_roster_messaging is true",
+            it("will open a chatbox and be displayed inside it if allow_non_roster_messaging is true",
                 mock.initConverse(
                     [], {'allow_non_roster_messaging': false},
                     async function (_converse) {

+ 3 - 2
src/plugins/controlbox/styles/_controlbox.scss

@@ -188,11 +188,12 @@
             flex-direction: row-reverse;
 
             .toggle-controlbox {
-                order: -2;
-                text-align: center;
                 background-color: var(--controlbox-color);
+                color: var(--button-text-color);
                 float: right;
                 margin: 0 var(--overlayed-chat-gutter);
+                order: -2;
+                text-align: center;
             }
 
             #controlbox {

+ 7 - 5
src/plugins/modal/styles/_modal.scss

@@ -5,14 +5,16 @@ $prefix: 'converse-';
 
 .conversejs {
     .modal-header {
+        background-color: var(--primary-color);
+        border-bottom: none;
         &.alert-danger {
             background-color: var(--error-color);
+        }
+        .modal-title {
+            color: var(--background-color);
+        }
+        .close {
             color: var(--background-color);
-            border-bottom: none;
-
-            .close {
-                color: var(--background-color);
-            }
         }
     }
 

+ 46 - 22
src/plugins/rosterview/contactview.js

@@ -1,8 +1,10 @@
+import { Model } from '@converse/skeletor';
+import { _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 { CustomElement } from 'shared/components/element.js';
+import tplUnsavedContact from "./templates/unsaved_contact.js";
 import { __ } from 'i18n';
-import { _converse, api, log } from "@converse/headless";
 
 
 export default class RosterContact extends CustomElement {
@@ -28,27 +30,33 @@ export default class RosterContact extends CustomElement {
 
     render () {
         if (this.model.get('requesting') === true) {
-            const display_name = this.model.getDisplayName();
-            return tplRequestingContact(
-                Object.assign(this.model.toJSON(), {
-                    display_name,
-                    'openChat': ev => this.openChat(ev),
-                    'acceptRequest': ev => this.acceptRequest(ev),
-                    'declineRequest': ev => this.declineRequest(ev),
-                    'desc_accept': __("Click to accept the contact request from %1$s", display_name),
-                    'desc_decline': __("Click to decline the contact request from %1$s", display_name),
-                })
-            );
+            return tplRequestingContact(this);
+        } else if (this.model.get('subscription') === 'none') {
+            return tplUnsavedContact(this);
         } else {
             return tplRosterItem(this);
         }
     }
 
+    /**
+     * @param {MouseEvent} ev
+     */
     openChat (ev) {
         ev?.preventDefault?.();
         this.model.openChat();
     }
 
+    /**
+     * @param {MouseEvent} ev
+     */
+    addContact(ev) {
+        ev?.preventDefault?.();
+        api.modal.show('converse-add-contact-modal', {'model': new Model()}, ev);
+    }
+
+    /**
+     * @param {MouseEvent} ev
+     */
     async removeContact (ev) {
         ev?.preventDefault?.();
         if (!api.settings.get('allow_contact_removal')) { return; }
@@ -56,12 +64,19 @@ export default class RosterContact extends CustomElement {
         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 {
-            this.model.removeFromRoster();
-            if (this.model.collection) {
-                // The model might have already been removed as
-                // result of a roster push.
+            if (this.model.get('subscription') === 'none') {
                 this.model.destroy();
+            } else {
+                this.model.removeFromRoster();
+                if (this.model.collection) {
+                    // The model might have already been removed as
+                    // result of a roster push.
+                    this.model.destroy();
+                }
             }
         } catch (e) {
             log.error(e);
@@ -71,21 +86,30 @@ export default class RosterContact extends CustomElement {
         }
     }
 
+    /**
+     * @param {MouseEvent} ev
+     */
     async acceptRequest (ev) {
         ev?.preventDefault?.();
 
-        await _converse.state.roster.sendContactAddIQ(
-            this.model.get('jid'),
-            this.model.getFullname(),
-            []
-        );
+        await _converse.state.roster.sendContactAddIQ({
+            jid: this.model.get('jid'),
+            name: this.model.getFullname(),
+            groups: []
+        });
         this.model.authorize().subscribe();
     }
 
+    /**
+     * @param {MouseEvent} ev
+     */
     async declineRequest (ev) {
         if (ev && ev.preventDefault) { ev.preventDefault(); }
         const result = await api.confirm(__("Are you sure you want to decline this contact request?"));
         if (result) {
+            const chat = await api.chats.get(this.model.get('jid'));
+            chat?.close();
+
             this.model.unauthorize().destroy();
         }
         return this;

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

@@ -40,10 +40,7 @@ export default class AddContactModal extends BaseModal {
     }
 
     afterSubmission (_form, jid, name, group) {
-        if (group && !Array.isArray(group)) {
-            group = [group];
-        }
-        _converse.state.roster.addAndSubscribe(jid, name, group);
+        _converse.state.roster.addAndSubscribe({ jid, name, groups: Array.isArray(group) ? group : [group] });
         this.model.clear();
         this.modal.hide();
     }

+ 1 - 1
src/plugins/rosterview/styles/roster.scss

@@ -24,7 +24,7 @@
         padding: 0;
 
         svg {
-            fill: var(--chat-color);
+            fill: var(--chat-color) !important;
         }
 
         .roster-contacts {

+ 3 - 0
src/plugins/rosterview/templates/group.js

@@ -8,6 +8,9 @@ import { toggleGroup } from '../utils.js';
 const { isUniView } = u;
 
 
+/**
+ * @param {import('@converse/headless/types/plugins/roster/contact').default} contact
+ */
 function renderContact (contact) {
     const jid = contact.get('jid');
     const extra_classes = [];

+ 31 - 17
src/plugins/rosterview/templates/requesting_contact.js

@@ -1,19 +1,33 @@
-import { html } from "lit";
+import { html } from 'lit';
+import { __ } from 'i18n';
 
-export default (o) => html`
-   <a class="open-chat w-100" href="#" @click=${o.openChat}>
-      <span class="req-contact-name w-100" title="JID: ${o.jid}">${o.display_name}</span>
-   </a>
-   <a class="accept-xmpp-request list-item-action list-item-action--visible"
-      @click=${o.acceptRequest}
-      aria-label="${o.desc_accept}" title="${o.desc_accept}" href="#">
+/**
+ * @param {import('../contactview').default} el
+ */
+export default (el) => {
+    const display_name = el.model.getDisplayName();
+    const desc_accept = __('Click to accept the contact request from %1$s', display_name);
+    const desc_decline = __('Click to decline the contact request from %1$s', display_name);
+    return html` <a class="open-chat w-100" href="#" @click="${(ev) => el.openChat(ev)}">
+            <span class="req-contact-name w-100" title="JID: ${el.model.get('jid')}">${display_name}</span>
+        </a>
+        <a
+            class="accept-xmpp-request list-item-action list-item-action--visible"
+            @click=${(ev) => el.acceptRequest(ev)}
+            aria-label="${desc_accept}"
+            title="${desc_accept}"
+            href="#"
+        >
+            <converse-icon class="fa fa-check" size="1em"></converse-icon>
+        </a>
 
-      <converse-icon class="fa fa-check" size="1em"></converse-icon>
-   </a>
-
-   <a class="decline-xmpp-request list-item-action list-item-action--visible"
-      @click=${o.declineRequest}
-      aria-label="${o.desc_decline}" title="${o.desc_decline}" href="#">
-
-      <converse-icon class="fa fa-times" size="1em"></converse-icon>
-   </a>`;
+        <a
+            class="decline-xmpp-request list-item-action list-item-action--visible"
+            @click=${(ev) => el.declineRequest(ev)}
+            aria-label="${desc_decline}"
+            title="${desc_decline}"
+            href="#"
+        >
+            <converse-icon class="fa fa-times" size="1em"></converse-icon>
+        </a>`;
+};

+ 1 - 1
src/plugins/rosterview/templates/roster_item.js

@@ -10,7 +10,7 @@ import { STATUSES } from '../constants.js';
 /**
  * @param {RosterContact} el
  */
-const tplRemoveLink = (el) => {
+export const tplRemoveLink = (el) => {
    const display_name = el.model.getDisplayName();
    const i18n_remove = __('Click to remove %1$s as a contact', display_name);
    return html`

+ 51 - 0
src/plugins/rosterview/templates/unsaved_contact.js

@@ -0,0 +1,51 @@
+import { __ } from 'i18n';
+import { api } from '@converse/headless';
+import { html } from 'lit';
+import { getUnreadMsgsDisplay } from 'shared/chat/utils.js';
+import { tplRemoveLink } from './roster_item';
+
+/**
+ * @param {import('../contactview').default} el
+ */
+export default (el) => {
+    const num_unread = getUnreadMsgsDisplay(el.model);
+    const display_name = el.model.getDisplayName();
+    const jid = el.model.get('jid');
+
+    const i18n_add_contact = __('Click to add %1$s to your roster', display_name);
+    const i18n_chat = __('Click to chat with %1$s (XMPP address: %2$s)', display_name, jid);
+    return html`
+        <a
+            class="list-item-link cbox-list-item open-chat ${num_unread ? 'unread-msgs' : ''}"
+            title="${i18n_chat}"
+            href="#"
+            data-jid=${jid}
+            @click=${el.openChat}
+        >
+            <span>
+                <converse-avatar
+                    .model=${el.model}
+                    class="avatar"
+                    name="${el.model.getDisplayName()}"
+                    nonce=${el.model.vcard?.get('vcard_updated')}
+                    height="30"
+                    width="30"
+                ></converse-avatar>
+            </span>
+            ${num_unread ? html`<span class="msgs-indicator badge">${num_unread}</span>` : ''}
+            <span class="contact-name ${num_unread ? 'unread-msgs' : ''}">${display_name}</span>
+        </a>
+        <span>
+            <a
+                class="add-contact list-item-action"
+                @click="${(ev) => el.addContact(ev)}"
+                aria-label="${i18n_add_contact}"
+                title="${i18n_add_contact}"
+                href="#"
+            >
+                <converse-icon class="fa fa-user-plus" size="1.5em"></converse-icon>
+            </a>
+            ${api.settings.get('allow_contact_removal') ? tplRemoveLink(el) : ''}
+        </span>
+    `;
+};

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

@@ -38,7 +38,7 @@ describe("The 'Add Contact' widget", function () {
         const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
         expect(Strophe.serialize(sent_stanza)).toEqual(
             `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit" name="Someone"/></query>`+
+                `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit" name="Someone"><group></group></item></query>`+
             `</iq>`);
     }));
 
@@ -65,7 +65,7 @@ describe("The 'Add Contact' widget", function () {
         );
         expect(Strophe.serialize(sent_stanza)).toEqual(
             `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit"/></query>`+
+                `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit"><group></group></item></query>`+
             `</iq>`
         );
     }));
@@ -177,7 +177,7 @@ describe("The 'Add Contact' widget", function () {
         const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
         expect(Strophe.serialize(sent_stanza)).toEqual(
         `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-            `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
+            `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"><group></group></item></query>`+
         `</iq>`);
     }));
 });

+ 2 - 2
src/plugins/rosterview/tests/protocol.js

@@ -52,7 +52,7 @@ describe("The Protocol", function () {
             const cbview = _converse.chatboxviews.get('controlbox');
 
             spyOn(_converse.roster, "addAndSubscribe").and.callThrough();
-            spyOn(_converse.roster, "addContactToRoster").and.callThrough();
+            spyOn(_converse.roster, "addContact").and.callThrough();
             spyOn(_converse.roster, "sendContactAddIQ").and.callThrough();
             spyOn(_converse.api.vcard, "get").and.callThrough();
 
@@ -76,7 +76,7 @@ describe("The Protocol", function () {
              * for the new roster item.
              */
             expect(_converse.roster.addAndSubscribe).toHaveBeenCalled();
-            expect(_converse.roster.addContactToRoster).toHaveBeenCalled();
+            expect(_converse.roster.addContact).toHaveBeenCalled();
 
             /* The request consists of sending an IQ
              * stanza of type='set' containing a <query/> element qualified by

+ 5 - 0
src/plugins/rosterview/types.ts

@@ -0,0 +1,5 @@
+import RosterContact from "@converse/headless/types/plugins/roster/contact"
+
+export type ContactsMap = {
+    [Key: string]: RosterContact[]
+}

+ 22 - 17
src/plugins/rosterview/utils.js

@@ -109,28 +109,33 @@ export function shouldShowGroup (group, model) {
     return true;
 }
 
+/**
+ * @param {import('./types').ContactsMap} contacts_map
+ * @param {RosterContact} contact
+ * @returns {import('./types').ContactsMap}
+ */
 export function populateContactsMap (contacts_map, contact) {
+    const { labels } = _converse;
+    let contact_groups;
     if (contact.get('requesting')) {
-        const name = /** @type {string} */(_converse.labels.HEADER_REQUESTING_CONTACTS);
-        contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
+        contact_groups = [labels.HEADER_REQUESTING_CONTACTS];
+    } else if (contact.get('ask') === 'subscribe') {
+        contact_groups = [labels.HEADER_PENDING_CONTACTS];
+    } else if (contact.get('subscription') === 'none') {
+        contact_groups = [labels.HEADER_UNSAVED_CONTACTS];
+    } else if (!api.settings.get('roster_groups')) {
+        contact_groups = [labels.HEADER_CURRENT_CONTACTS];
     } else {
-        let contact_groups;
-        if (api.settings.get('roster_groups')) {
-            contact_groups = contact.get('groups');
-            contact_groups = (contact_groups.length === 0) ? [_converse.labels.HEADER_UNGROUPED] : contact_groups;
-        } else {
-            if (contact.get('ask') === 'subscribe') {
-                contact_groups = [_converse.labels.HEADER_PENDING_CONTACTS];
-            } else {
-                contact_groups = [_converse.labels.HEADER_CURRENT_CONTACTS];
-            }
-        }
-        for (const name of contact_groups) {
-            contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
-        }
+        contact_groups = contact.get('groups');
+        contact_groups = (contact_groups.length === 0) ? [labels.HEADER_UNGROUPED] : contact_groups;
     }
+
+    for (const name of contact_groups) {
+        contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
+    }
+
     if (contact.get('num_unread')) {
-        const name = /** @type {string} */(_converse.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;

+ 2 - 2
src/types/plugins/chatview/bottom-panel.d.ts

@@ -14,12 +14,12 @@ export default class ChatBottomPanel extends CustomElement {
     clearMessages(ev: any): void;
     /**
      * @typedef {Object} AutocompleteInPickerEvent
-     * @property {HTMLTextAreaElement} input
+     * @property {HTMLTextAreaElement} target
      * @property {string} value
      * @param {AutocompleteInPickerEvent} ev
      */
     autocompleteInPicker(ev: {
-        input: HTMLTextAreaElement;
+        target: HTMLTextAreaElement;
         value: string;
     }): Promise<void>;
 }

+ 17 - 0
src/types/plugins/muc-views/occupant-bottom-panel.d.ts

@@ -0,0 +1,17 @@
+export default class OccupantBottomPanel extends BottomPanel {
+    static get properties(): {
+        model: {
+            type: ObjectConstructor;
+            noAccessor: boolean;
+        };
+        muc: {
+            type: ObjectConstructor;
+        };
+    };
+    muc: any;
+    canPostMessages(): boolean;
+    openChat(): any;
+    invite(): any;
+}
+import BottomPanel from 'plugins/chatview/bottom-panel.js';
+//# sourceMappingURL=occupant-bottom-panel.d.ts.map

+ 3 - 0
src/types/plugins/muc-views/templates/occupant-bottom-panel.d.ts

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

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

@@ -7,10 +7,26 @@ export default class RosterContact extends CustomElement {
     model: any;
     initialize(): void;
     render(): import("lit").TemplateResult<1>;
-    openChat(ev: any): void;
-    removeContact(ev: any): Promise<void>;
-    acceptRequest(ev: any): Promise<void>;
-    declineRequest(ev: any): Promise<this>;
+    /**
+     * @param {MouseEvent} ev
+     */
+    openChat(ev: MouseEvent): void;
+    /**
+     * @param {MouseEvent} ev
+     */
+    addContact(ev: MouseEvent): void;
+    /**
+     * @param {MouseEvent} ev
+     */
+    removeContact(ev: MouseEvent): Promise<void>;
+    /**
+     * @param {MouseEvent} ev
+     */
+    acceptRequest(ev: MouseEvent): Promise<void>;
+    /**
+     * @param {MouseEvent} ev
+     */
+    declineRequest(ev: MouseEvent): Promise<this>;
 }
 import { CustomElement } from 'shared/components/element.js';
 //# sourceMappingURL=contactview.d.ts.map

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

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

+ 1 - 0
src/types/plugins/rosterview/templates/roster_item.d.ts

@@ -1,3 +1,4 @@
+export function tplRemoveLink(el: RosterContact): import("lit").TemplateResult<1>;
 declare function _default(el: RosterContact): import("lit").TemplateResult<1>;
 export default _default;
 export type RosterContact = import("../contactview").default;

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

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

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

@@ -0,0 +1,5 @@
+import RosterContact from "@converse/headless/types/plugins/roster/contact";
+export type ContactsMap = {
+    [Key: string]: RosterContact[];
+};
+//# sourceMappingURL=types.d.ts.map

+ 6 - 1
src/types/plugins/rosterview/utils.d.ts

@@ -15,7 +15,12 @@ export function isContactFiltered(contact: RosterContact, groupname: string): bo
  */
 export function shouldShowContact(contact: RosterContact, groupname: string, model: Model): boolean;
 export function shouldShowGroup(group: any, model: any): boolean;
-export function populateContactsMap(contacts_map: any, contact: any): any;
+/**
+ * @param {import('./types').ContactsMap} contacts_map
+ * @param {RosterContact} contact
+ * @returns {import('./types').ContactsMap}
+ */
+export function populateContactsMap(contacts_map: import("./types").ContactsMap, contact: RosterContact): import("./types").ContactsMap;
 export function contactsComparator(contact1: any, contact2: any): 0 | 1 | -1;
 export function groupsComparator(a: any, b: any): 0 | 1 | -1;
 export function getGroupsAutoCompleteList(): any[];