Browse Source

Add support for showing self in the roster

Fixes #3038
JC Brand 6 months ago
parent
commit
1904d706e5

+ 3 - 3
src/headless/plugins/chat/api.js

@@ -108,9 +108,9 @@ export default {
          *
          * @method api.chats.get
          * @param {String|string[]} jids - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
-         * @param { Object } [attrs] - Attributes to be set on the _converse.ChatBox model.
-         * @param { Boolean } [create=false] - Whether the chat should be created if it's not found.
-         * @returns { Promise<ChatBox[]> }
+         * @param {Object} [attrs] - Attributes to be set on the _converse.ChatBox model.
+         * @param {Boolean} [create=false] - Whether the chat should be created if it's not found.
+         * @returns {Promise<ChatBox[]>}
          *
          * @example
          * // To return a single chat, provide the JID of the contact you're chatting with in that chat:

+ 7 - 18
src/headless/plugins/roster/contact.js

@@ -59,25 +59,15 @@ class RosterContact extends ColorAwareModel(Model) {
         this.presence = presences.findWhere(jid) || presences.create({ jid });
     }
 
-    openChat () {
-        api.chats.open(this.get('jid'), this.attributes, true);
+    getStatus () {
+        return this.presence.get('show') || 'offline';
     }
 
-    /**
-     * Return a string of tab-separated values that are to be used when
-     * matching against filter text.
-     *
-     * The goal is to be able to filter against the VCard fullname,
-     * roster nickname and JID.
-     * @returns {string} Lower-cased, tab-separated values
-     */
-    getFilterCriteria () {
-        const nick = this.get('nickname');
-        const jid = this.get('jid');
-        let criteria = this.getDisplayName();
-        criteria = !criteria.includes(jid) ? criteria.concat(`   ${jid}`) : criteria;
-        criteria = !criteria.includes(nick) ? criteria.concat(`   ${nick}`) : criteria;
-        return criteria.toLowerCase();
+    openChat () {
+        // XXX: Doubtful whether it's necessary to pass in the contact
+        // attributes hers. If so, we should perhaps look them up inside the
+        // `open` API method.
+        api.chats.open(this.get('jid'), this.attributes, true);
     }
 
     getDisplayName () {
@@ -154,7 +144,6 @@ class RosterContact extends ColorAwareModel(Model) {
         return this;
     }
 
-
     /**
      * Remove this contact from the roster
      * @param {boolean} unauthorize - Whether to also unauthorize the

+ 4 - 2
src/headless/plugins/roster/contacts.js

@@ -280,14 +280,16 @@ class RosterContacts extends Collection {
     /**
      * Fetch the roster from the XMPP server
      * @emits _converse#roster
+     * @param {boolean} [full=false] - Whether to fetch the full roster or just the changes.
      * @returns {promise}
      */
-    async fetchFromServer () {
+    async fetchFromServer (full=false) {
         const stanza = $iq({
             'type': 'get',
             'id': u.getUniqueId('roster'),
         }).c('query', { xmlns: Strophe.NS.ROSTER });
-        if (this.rosterVersioningSupported()) {
+
+        if (this.rosterVersioningSupported() && !full) {
             stanza.attrs({ 'ver': this.data.get('version') });
         }
 

+ 4 - 3
src/headless/plugins/roster/plugin.js

@@ -26,9 +26,10 @@ converse.plugins.add('converse-roster', {
 
     initialize () {
         api.settings.extend({
-            'allow_contact_requests': true,
-            'auto_subscribe': false,
-            'synchronize_availability': true
+            show_self_in_roster: true,
+            allow_contact_requests: true,
+            auto_subscribe: false,
+            synchronize_availability: true
         });
 
         api.promises.add(['cachedRoster', 'roster', 'rosterContactsFetched', 'rosterInitialized']);

+ 4 - 0
src/headless/plugins/status/status.js

@@ -18,6 +18,10 @@ export default class XMPPStatus extends ColorAwareModel(Model) {
         return { "status":  api.settings.get("default_state") }
     }
 
+    getStatus () {
+        return this.get('status');
+    }
+
     /**
      * @param {string} attr
      */

+ 3 - 3
src/headless/types/plugins/chat/api.d.ts

@@ -55,9 +55,9 @@ declare namespace _default {
          *
          * @method api.chats.get
          * @param {String|string[]} jids - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
-         * @param { Object } [attrs] - Attributes to be set on the _converse.ChatBox model.
-         * @param { Boolean } [create=false] - Whether the chat should be created if it's not found.
-         * @returns { Promise<ChatBox[]> }
+         * @param {Object} [attrs] - Attributes to be set on the _converse.ChatBox model.
+         * @param {Boolean} [create=false] - Whether the chat should be created if it's not found.
+         * @returns {Promise<ChatBox[]>}
          *
          * @example
          * // To return a single chat, provide the JID of the contact you're chatting with in that chat:

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

@@ -80,16 +80,8 @@ declare class RosterContact extends RosterContact_base {
     initialized: any;
     setPresence(): void;
     presence: any;
+    getStatus(): any;
     openChat(): void;
-    /**
-     * Return a string of tab-separated values that are to be used when
-     * matching against filter text.
-     *
-     * The goal is to be able to filter against the VCard fullname,
-     * roster nickname and JID.
-     * @returns {string} Lower-cased, tab-separated values
-     */
-    getFilterCriteria(): string;
     getDisplayName(): any;
     getFullname(): any;
     /**

+ 2 - 1
src/headless/types/plugins/roster/contacts.d.ts

@@ -68,9 +68,10 @@ declare class RosterContacts extends Collection {
     /**
      * Fetch the roster from the XMPP server
      * @emits _converse#roster
+     * @param {boolean} [full=false] - Whether to fetch the full roster or just the changes.
      * @returns {promise}
      */
-    fetchFromServer(): Promise<any>;
+    fetchFromServer(full?: boolean): Promise<any>;
     /**
      * Update or create RosterContact models based on the given `item` XML
      * node received in the resulting IQ stanza from the server.

+ 1 - 0
src/headless/types/plugins/status/status.d.ts

@@ -74,6 +74,7 @@ export default class XMPPStatus extends XMPPStatus_base {
     defaults(): {
         status: any;
     };
+    getStatus(): any;
     /**
      * @param {string|Object} key
      * @param {string|Object} [val]

+ 7 - 0
src/headless/types/utils/array.d.ts

@@ -0,0 +1,7 @@
+/**
+ * @template {any} T
+ * @param {Array<T>} arr
+ * @returns {Array<T>} A new array containing only unique elements from the input array.
+ */
+export function unique<T extends unknown>(arr: Array<T>): Array<T>;
+//# sourceMappingURL=array.d.ts.map

+ 1 - 0
src/headless/types/utils/index.d.ts

@@ -88,6 +88,7 @@ declare const _default: {
     arrayBufferToBase64(ab: any): string;
     base64ToArrayBuffer(b64: any): ArrayBufferLike;
     hexToArrayBuffer(hex: any): ArrayBufferLike;
+    unique<T extends unknown>(arr: Array<T>): Array<T>;
 } & CommonUtils & PluginUtils;
 export default _default;
 export type CommonUtils = Record<string, Function>;

+ 8 - 0
src/headless/utils/array.js

@@ -0,0 +1,8 @@
+/**
+ * @template {any} T
+ * @param {Array<T>} arr
+ * @returns {Array<T>} A new array containing only unique elements from the input array.
+ */
+export function unique (arr) {
+    return [...new Set(arr)];
+}

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

@@ -5,6 +5,7 @@
  */
 import { Model } from '@converse/skeletor';
 import log, { LEVELS } from '../log.js';
+import * as array from './array.js';
 import * as arraybuffer from './arraybuffer.js';
 import * as color from './color.js';
 import * as form from './form.js';
@@ -148,6 +149,7 @@ export function getUniqueId (suffix) {
 }
 
 export default Object.assign({
+    ...array,
     ...arraybuffer,
     ...color,
     ...form,

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

@@ -72,7 +72,7 @@ describe("Chatboxes", function () {
 
 
         it("is created when you click on a roster item", mock.initConverse(
-                ['chatBoxesFetched'], {}, async function (_converse) {
+                ['chatBoxesFetched'], { show_self_in_roster: false }, async function (_converse) {
 
             await mock.waitForRoster(_converse, 'current');
             await mock.openControlBox(_converse);

+ 1 - 1
src/plugins/controlbox/tests/controlbox.js

@@ -34,7 +34,7 @@ describe("The Controlbox", function () {
     describe("The \"Contacts\" section", function () {
 
         it("can be used to add contact and it checks for case-sensivity",
-                mock.initConverse([], {}, async function (_converse) {
+                mock.initConverse([], { show_self_in_roster: false }, async function (_converse) {
 
             spyOn(_converse.api, "trigger").and.callThrough();
             await mock.waitForRoster(_converse, 'all', 0);

+ 1 - 1
src/plugins/minimize/tests/minchats.js

@@ -133,7 +133,7 @@ describe("A Chatbox", function () {
     it("can be trimmed to conserve space",
             mock.initConverse(
                 [],
-                { no_trimming: false },
+                { no_trimming: false, show_self_in_roster: false },
                 async function (_converse) {
 
         await mock.waitForRoster(_converse, 'current');

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

@@ -45,7 +45,7 @@ export default class RosterContact extends CustomElement {
      */
     openChat (ev) {
         ev?.preventDefault?.();
-        this.model.openChat();
+        api.chats.open(this.model.get('jid'), this.model.attributes, true);
     }
 
     /**

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

@@ -34,8 +34,8 @@ converse.plugins.add('converse-rosterview', {
 
         /* -------- Event Handlers ----------- */
         api.listen.on('chatBoxesInitialized', () => {
-            _converse.state.chatboxes.on('destroy', c => highlightRosterItem(c));
-            _converse.state.chatboxes.on('change:hidden', c => highlightRosterItem(c));
+            _converse.state.chatboxes.on('destroy', c => highlightRosterItem(c.get('jid')));
+            _converse.state.chatboxes.on('change:hidden', c => highlightRosterItem(c.get('jid')));
         });
     }
 });

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

@@ -41,16 +41,16 @@ function renderContact (contact) {
     } else if (subscription === 'both' || subscription === 'to' || u.isSameBareJID(jid, api.connection.get().jid)) {
         extra_classes.push('current-xmpp-contact');
         extra_classes.push(subscription);
-        extra_classes.push(contact.presence.get('show'));
+        extra_classes.push(contact.getStatus());
     }
     return html`
-        <li class="list-item d-flex controlbox-padded ${extra_classes.join(' ')}" data-status="${contact.presence.get('show')}">
+        <li class="list-item d-flex controlbox-padded ${extra_classes.join(' ')}" data-status="${contact.getStatus()}">
             <converse-roster-contact .model=${contact}></converse-roster-contact>
         </li>`;
 }
 
 
-export default  (o) => {
+export default (o) => {
     const i18n_title = __('Click to hide these contacts');
     const collapsed = _converse.state.roster.state.get('collapsed_groups');
     return html`

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

@@ -22,7 +22,12 @@ export default (el) => {
     const i18n_toggle_contacts = __('Click to toggle contacts');
     const i18n_title_add_contact = __('Add a contact');
     const i18n_title_new_chat = __('Start a new chat');
-    const roster = _converse.state.roster || [];
+    const { state } = _converse;
+    const roster = [
+        ...(state.roster || []),
+        ...api.settings.get('show_self_in_roster') ? [state.xmppstatus] : []
+    ];
+
     const contacts_map = roster.reduce((acc, contact) => populateContactsMap(acc, contact), {});
     const groupnames = Object.keys(contacts_map).filter((contact) => shouldShowGroup(contact, el.model));
     const is_closed = el.model.get('toggle_state') === CLOSED;
@@ -96,7 +101,7 @@ export default (el) => {
             <span class="w-100 controlbox-heading controlbox-heading--contacts">
                 <a class="list-toggle open-contacts-toggle" title="${i18n_toggle_contacts}"
                     role="heading" aria-level="3"
-                    @click=${el.toggleRoster}>
+                    @click="${el.toggleRoster}">
                     ${i18n_heading_contacts}
 
                     ${ roster.length ? html`<converse-icon

+ 15 - 10
src/plugins/rosterview/templates/roster_item.js

@@ -1,14 +1,11 @@
-/**
- * @typedef {import('../contactview').default} RosterContact
- */
 import { __ } from 'i18n';
-import { api } from "@converse/headless";
+import { _converse, api } from "@converse/headless";
 import { html } from "lit";
 import { getUnreadMsgsDisplay } from 'shared/chat/utils.js';
 import { STATUSES } from '../constants.js';
 
 /**
- * @param {RosterContact} el
+ * @param {import('../contactview').default} el
  */
 export const tplRemoveLink = (el) => {
    const display_name = el.model.getDisplayName();
@@ -21,10 +18,11 @@ export const tplRemoveLink = (el) => {
 }
 
 /**
- * @param {RosterContact} el
+ * @param {import('../contactview').default} el
  */
 export default  (el) => {
-   const show = el.model.presence.get('show') || 'offline';
+   const bare_jid = _converse.session.get('bare_jid');
+   const show = el.model.getStatus() || 'offline';
     let classes, color;
     if (show === 'online') {
         [classes, color] = ['fa fa-circle', 'chat-status-online'];
@@ -35,11 +33,16 @@ export default  (el) => {
     } else {
         [classes, color] = ['fa fa-circle', 'comment'];
     }
+
+   const is_self = bare_jid === el.model.get('jid');
    const desc_status = STATUSES[show];
    const num_unread = getUnreadMsgsDisplay(el.model);
    const display_name = el.model.getDisplayName();
    const jid = el.model.get('jid');
-   const i18n_chat = __('Click to chat with %1$s (XMPP address: %2$s)', display_name, jid);
+   const i18n_chat = is_self ?
+      __('Click to chat with yourself') :
+      __('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}"
@@ -60,7 +63,9 @@ export default  (el) => {
             class="${classes} chat-status chat-status--avatar"></converse-icon>
       </span>
       ${ num_unread ? html`<span class="msgs-indicator badge">${ num_unread }</span>` : '' }
-      <span class="contact-name contact-name--${show} ${ num_unread ? 'unread-msgs' : ''}">${display_name}</span>
+   <span class="contact-name contact-name--${show} ${ num_unread ? 'unread-msgs' : ''}">${display_name + (is_self ? ` ${__('(me)')}` : '')}</span>
    </a>
-   ${ api.settings.get('allow_contact_removal') ? tplRemoveLink(el) : '' }`;
+   <span class="contact-actions">
+      ${ api.settings.get('allow_contact_removal') && !is_self ? tplRemoveLink(el) : '' }
+   </span>`;
 }

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

@@ -4,7 +4,7 @@
 
 const { u, $iq, $pres, sizzle, Strophe, stx } = converse.env;
 
-describe("The Protocol", function () {
+describe("Presence subscriptions", function () {
 
     beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
 
@@ -38,7 +38,7 @@ describe("The Protocol", function () {
          * stanza of type "result".
          */
         it("Subscribe to contact, contact accepts and subscribes back",
-                mock.initConverse([], { roster_groups: false }, async function (_converse) {
+                mock.initConverse([], { show_self_in_roster: false, roster_groups: false }, async function (_converse) {
 
             let stanza;
             await mock.waitForRoster(_converse, 'current', 0);

+ 26 - 22
src/plugins/rosterview/tests/roster.js

@@ -226,7 +226,7 @@ describe("The Contacts Roster", function () {
         dropdown.querySelector('.toggle-filter').click();
 
         const filter = await u.waitUntil(() => rosterview.querySelector('.items-filter'));
-        await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 800);
+        await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 18), 800);
         filter.value = "la";
         u.triggerEvent(filter, "keydown", "KeyboardEvent");
         await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 4), 800);
@@ -262,7 +262,7 @@ describe("The Contacts Roster", function () {
     describe("The live filter", function () {
 
         it("will only be an option when there are more than 5 contacts",
-                mock.initConverse([], {}, async function (_converse) {
+                mock.initConverse([], { show_self_in_roster: false }, async function (_converse) {
 
             expect(document.querySelector('converse-roster')).toBe(null);
             await mock.waitForRoster(_converse, 'current', 5);
@@ -292,7 +292,7 @@ describe("The Contacts Roster", function () {
             const rosterview = document.querySelector('converse-roster');
             const roster = rosterview.querySelector('.roster-contacts');
 
-            await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
+            await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 18), 600);
             expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
 
             const filter_toggle = await u.waitUntil(() => rosterview.querySelector('.toggle-filter'));
@@ -335,7 +335,7 @@ describe("The Contacts Roster", function () {
             filter = rosterview.querySelector('.items-filter');
             filter.value = "";
             u.triggerEvent(filter, "keydown", "KeyboardEvent");
-            await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
+            await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 18), 600);
             expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
         }));
 
@@ -351,7 +351,7 @@ describe("The Contacts Roster", function () {
             const button =  await u.waitUntil(() => rosterview.querySelector('converse-icon[data-type="groups"]'));
             button.click();
 
-            await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
+            await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 18), 600);
             expect(sizzle('.roster-group', roster).filter(u.isVisible).length).toBe(5);
 
             let filter = rosterview.querySelector('.items-filter');
@@ -426,23 +426,26 @@ describe("The Contacts Roster", function () {
             u.triggerEvent(filter, 'change');
 
             const roster = rosterview.querySelector('.roster-contacts');
-            await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 20, 900);
+            await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 21, 900);
             expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(6);
 
             filter.value = "online";
             u.triggerEvent(filter, 'change');
 
-            await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 1, 900);
-            expect(sizzle('li', roster).filter(u.isVisible).pop().querySelector('.contact-name').textContent.trim()).toBe('Lord Montague');
+            await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 2, 900);
+            const contacts = sizzle('li', roster).filter(u.isVisible);
+            expect(contacts.pop().querySelector('.contact-name').textContent.trim()).toBe('Romeo Montague (me)');
+            expect(contacts.pop().querySelector('.contact-name').textContent.trim()).toBe('Lord Montague');
 
-            let ul = sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).pop();
-            expect(ul.parentElement.firstElementChild.textContent.trim()).toBe('Family');
+            const groups = sizzle('ul.roster-group-contacts', roster).filter(u.isVisible);
+            expect(groups.pop().parentElement.firstElementChild.textContent.trim()).toBe('Ungrouped');
+            expect(groups.pop().parentElement.firstElementChild.textContent.trim()).toBe('Family');
 
             filter.value = "dnd";
             u.triggerEvent(filter, 'change');
 
             await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).pop().querySelector('.contact-name').textContent.trim() === 'Friar Laurence', 900);
-            ul = sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).pop();
+            const ul = sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).pop();
             expect(ul.parentElement.firstElementChild.textContent.trim()).toBe('friends & acquaintences');
             expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(1);
         }));
@@ -541,7 +544,7 @@ describe("The Contacts Roster", function () {
         }));
 
         it("gets created when a contact's \"groups\" attribute changes",
-            mock.initConverse([], {'roster_groups': true}, async function (_converse) {
+            mock.initConverse([], {roster_groups: true, show_self_in_roster: false}, async function (_converse) {
 
             await mock.openControlBox(_converse);
             await mock.waitForRoster(_converse, 'current', 0);
@@ -596,7 +599,7 @@ describe("The Contacts Roster", function () {
                 });
             }
             const rosterview = document.querySelector('converse-roster');
-            await u.waitUntil(() => (sizzle('li', rosterview).filter(u.isVisible).length === 30));
+            await u.waitUntil(() => (sizzle('li', rosterview).filter(u.isVisible).length === 31));
             // Check that usernames appear alphabetically per group
             groups.forEach(name => {
                 const contacts = sizzle('.roster-group[data-group="'+name+'"] ul li', rosterview);
@@ -609,7 +612,7 @@ describe("The Contacts Roster", function () {
         }));
 
         it("remembers whether it is closed or opened",
-                mock.initConverse([], {}, async function (_converse) {
+                mock.initConverse([], { show_self_in_roster: false }, async function (_converse) {
 
             await mock.waitForRoster(_converse, 'current', 0);
             await mock.openControlBox(_converse);
@@ -687,8 +690,8 @@ describe("The Contacts Roster", function () {
             const rosterview = document.querySelector('converse-roster');
             await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length, 500)
             expect(u.isVisible(rosterview)).toBe(true);
-            expect(sizzle('li', rosterview).filter(u.isVisible).length).toBe(3);
-            expect(sizzle('ul.roster-group-contacts', rosterview).filter(u.isVisible).length).toBe(1);
+            expect(sizzle('li', rosterview).filter(u.isVisible).length).toBe(4);
+            expect(sizzle('ul.roster-group-contacts', rosterview).filter(u.isVisible).length).toBe(2);
         }));
 
         it("can be removed by the user", mock.initConverse([], {'roster_groups': false}, async function (_converse) {
@@ -826,7 +829,7 @@ describe("The Contacts Roster", function () {
 
         it("will be hidden when appearing under a collapsed group",
             mock.initConverse(
-                [], {'roster_groups': false},
+                [], { roster_groups: false, show_self_in_roster: false },
                 async function (_converse) {
 
             await _addContacts(_converse);
@@ -842,7 +845,7 @@ describe("The Contacts Roster", function () {
                 requesting: false,
                 subscription: 'both'
             });
-            await u.waitUntil(() => u.hasClass('collapsed', rosterview.querySelector(`ul[data-group="My contacts"]`)) === true);
+            await u.waitUntil(() => u.hasClass('collapsed', rosterview.querySelector(`ul[data-group="Colleagues"]`)) === true);
             expect(true).toBe(true);
         }));
 
@@ -934,7 +937,7 @@ describe("The Contacts Roster", function () {
 
         it("do not have a header if there aren't any",
             mock.initConverse(
-                [], {},
+                [], { show_self_in_roster: false },
                 async function (_converse) {
 
             await mock.openControlBox(_converse);
@@ -961,7 +964,7 @@ describe("The Contacts Roster", function () {
 
         it("can change their status to online and be sorted alphabetically",
             mock.initConverse(
-                [], {},
+                [], { show_self_in_roster: false },
                 async function (_converse) {
 
             await _addContacts(_converse);
@@ -1091,7 +1094,7 @@ describe("The Contacts Roster", function () {
 
         it("are ordered according to status: online, busy, away, xa, unavailable, offline",
             mock.initConverse(
-                [], {},
+                [], { show_self_in_roster: false },
                 async function (_converse) {
 
             await _addContacts(_converse);
@@ -1210,7 +1213,8 @@ describe("The Contacts Roster", function () {
             expect(names.join('')).toEqual(mock.req_names.slice(0,mock.req_names.length+1).sort().join(''));
         }));
 
-        it("do not have a header if there aren't any", mock.initConverse([], {}, async function (_converse) {
+        it("do not have a header if there aren't any",
+                mock.initConverse([], { show_self_in_roster: false }, async function (_converse) {
             await mock.openControlBox(_converse);
             await mock.waitForRoster(_converse, "current", 0);
             await mock.waitUntilDiscoConfirmed(

+ 2 - 1
src/plugins/rosterview/types.ts

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

+ 58 - 20
src/plugins/rosterview/utils.js

@@ -4,7 +4,7 @@
  * @typedef {import('@converse/headless').RosterContacts} RosterContacts
  */
 import { __ } from 'i18n';
-import { _converse, api, converse, log, constants } from "@converse/headless";
+import { _converse, api, converse, log, constants, u, XMPPStatus } from "@converse/headless";
 
 const { Strophe } = converse.env;
 const { STATUS_WEIGHTS } = constants;
@@ -26,10 +26,17 @@ export async function removeContact (contact) {
     }
 }
 
-export function highlightRosterItem (chatbox) {
-    _converse.state.roster?.get(chatbox.get('jid'))?.trigger('highlight');
+/**
+ * @param {string} jid
+ */
+export function highlightRosterItem (jid) {
+    _converse.state.roster?.get(jid)?.trigger('highlight');
 }
 
+/**
+ * @param {Event} ev
+ * @param {string} name
+ */
 export function toggleGroup (ev, name) {
     ev?.preventDefault?.();
     const { roster } = _converse.state;
@@ -42,7 +49,25 @@ export function toggleGroup (ev, name) {
 }
 
 /**
- * @param {RosterContact} contact
+ * Return a string of tab-separated values that are to be used when
+ * matching against filter text.
+ *
+ * The goal is to be able to filter against the VCard fullname,
+ * roster nickname and JID.
+ * @param {RosterContact|XMPPStatus} contact
+ * @returns {string} Lower-cased, tab-separated values
+ */
+function getFilterCriteria(contact) {
+    const nick = contact instanceof XMPPStatus ? contact.getNickname() : contact.get('nickname');
+    const jid = contact.get('jid');
+    let criteria = contact.getDisplayName();
+    criteria = !criteria.includes(jid) ? criteria.concat(`   ${jid}`) : criteria;
+    criteria = !criteria.includes(nick) ? criteria.concat(`   ${nick}`) : criteria;
+    return criteria.toLowerCase();
+}
+
+/**
+ * @param {RosterContact|XMPPStatus} contact
  * @param {string} groupname
  * @returns {boolean}
  */
@@ -65,12 +90,12 @@ export function isContactFiltered (contact, groupname) {
         } else if (q === 'unread_messages') {
             return contact.get('num_unread') === 0;
         } else if (q === 'online') {
-            return ["offline", "unavailable", "dnd", "away", "xa"].includes(contact.presence.get('show'));
+            return ["offline", "unavailable", "dnd", "away", "xa"].includes(contact.getStatus());
         } else {
-            return !contact.presence.get('show').includes(q);
+            return !contact.getStatus().includes(q);
         }
     } else if (type === 'items')  {
-        return !contact.getFilterCriteria().includes(q);
+        return !getFilterCriteria(contact).includes(q);
     }
 }
 
@@ -83,7 +108,7 @@ export function isContactFiltered (contact, groupname) {
 export function shouldShowContact (contact, groupname, model) {
     if (!model.get('filter_visible')) return true;
 
-    const chat_status = contact.presence.get('show');
+    const chat_status = contact.getStatus();
     if (api.settings.get('hide_offline_users') && chat_status === 'offline') {
         // If pending or requesting, show
         if ((contact.get('ask') === 'subscribe') ||
@@ -96,6 +121,10 @@ export function shouldShowContact (contact, groupname, model) {
     return !isContactFiltered(contact, groupname);
 }
 
+/**
+ * @param {string} group
+ * @param {Model} model
+ */
 export function shouldShowGroup (group, model) {
     if (!model.get('filter_visible')) return true;
 
@@ -114,27 +143,32 @@ export function shouldShowGroup (group, model) {
 }
 
 /**
+ * Populates a contacts map with the given contact, categorizing it into appropriate groups.
  * @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;
+
+    const contact_groups = /** @type {string[]} */(u.unique(contact.get('groups') ?? []));
+
     if (contact.get('requesting')) {
-        contact_groups = [labels.HEADER_REQUESTING_CONTACTS];
+        contact_groups.push(/** @type {string} */(labels.HEADER_REQUESTING_CONTACTS));
     } else if (contact.get('ask') === 'subscribe') {
-        contact_groups = [labels.HEADER_PENDING_CONTACTS];
+        contact_groups.push(/** @type {string} */(labels.HEADER_PENDING_CONTACTS));
     } else if (contact.get('subscription') === 'none') {
-        contact_groups = [labels.HEADER_UNSAVED_CONTACTS];
+        contact_groups.push(/** @type {string} */(labels.HEADER_UNSAVED_CONTACTS));
     } else if (!api.settings.get('roster_groups')) {
-        contact_groups = [labels.HEADER_CURRENT_CONTACTS];
-    } else {
-        contact_groups = contact.get('groups');
-        contact_groups = (contact_groups.length === 0) ? [labels.HEADER_UNGROUPED] : contact_groups;
+        contact_groups.push(/** @type {string} */(labels.HEADER_CURRENT_CONTACTS));
+    } else if (!contact_groups.length) {
+        contact_groups.push(/** @type {string} */(labels.HEADER_UNGROUPED));
     }
 
     for (const name of contact_groups) {
+        if (contacts_map[name]?.includes(contact)) {
+            continue;
+        }
         contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
     }
 
@@ -146,13 +180,13 @@ export function populateContactsMap (contacts_map, contact) {
 }
 
 /**
- * @param {RosterContact} contact1
- * @param {RosterContact} contact2
+ * @param {RosterContact|XMPPStatus} contact1
+ * @param {RosterContact|XMPPStatus} contact2
  * @returns {(-1|0|1)}
  */
 export function contactsComparator (contact1, contact2) {
-    const status1 = contact1.presence.get('show') || 'offline';
-    const status2 = contact2.presence.get('show') || 'offline';
+    const status1 = contact1.getStatus();
+    const status2 = contact2.getStatus();
     if (STATUS_WEIGHTS[status1] === STATUS_WEIGHTS[status2]) {
         const name1 = (contact1.getDisplayName()).toLowerCase();
         const name2 = (contact2.getDisplayName()).toLowerCase();
@@ -162,6 +196,10 @@ export function contactsComparator (contact1, contact2) {
     }
 }
 
+/**
+ * @param {string} a
+ * @param {string} b
+ */
 export function groupsComparator (a, b) {
     const HEADER_WEIGHTS = {};
     const {

+ 1 - 9
src/shared/chat/utils.js

@@ -181,18 +181,10 @@ export function getHats (message) {
     return [];
 }
 
-/**
- * @template {any} T
- * @param {Array<T>} arr
- * @returns {Array<T>} A new array containing only unique elements from the input array.
- */
-function unique (arr) {
-    return [...new Set(arr)];
-}
 
 export function getTonedEmojis () {
     if (!converse.emojis.toned) {
-        converse.emojis.toned = unique(
+        converse.emojis.toned = u.unique(
             Object.values(converse.emojis.json.people)
                 .filter(person => person.sn.includes('_tone'))
                 .map(person => person.sn.replace(/_tone[1-5]/, ''))

+ 2 - 3
src/types/plugins/rosterview/templates/roster_item.d.ts

@@ -1,5 +1,4 @@
-export function tplRemoveLink(el: RosterContact): import("lit").TemplateResult<1>;
-declare function _default(el: RosterContact): import("lit").TemplateResult<1>;
+export function tplRemoveLink(el: import("../contactview").default): import("lit").TemplateResult<1>;
+declare function _default(el: import("../contactview").default): import("lit").TemplateResult<1>;
 export default _default;
-export type RosterContact = import("../contactview").default;
 //# sourceMappingURL=roster_item.d.ts.map

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

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

+ 26 - 9
src/types/plugins/rosterview/utils.d.ts

@@ -2,14 +2,21 @@
  * @param {RosterContact} contact
  */
 export function removeContact(contact: RosterContact): Promise<void>;
-export function highlightRosterItem(chatbox: any): void;
-export function toggleGroup(ev: any, name: any): void;
 /**
- * @param {RosterContact} contact
+ * @param {string} jid
+ */
+export function highlightRosterItem(jid: string): void;
+/**
+ * @param {Event} ev
+ * @param {string} name
+ */
+export function toggleGroup(ev: Event, name: string): void;
+/**
+ * @param {RosterContact|XMPPStatus} contact
  * @param {string} groupname
  * @returns {boolean}
  */
-export function isContactFiltered(contact: RosterContact, groupname: string): boolean;
+export function isContactFiltered(contact: RosterContact | XMPPStatus, groupname: string): boolean;
 /**
  * @param {RosterContact} contact
  * @param {string} groupname
@@ -17,20 +24,29 @@ export function isContactFiltered(contact: RosterContact, groupname: string): bo
  * @returns {boolean}
  */
 export function shouldShowContact(contact: RosterContact, groupname: string, model: Model): boolean;
-export function shouldShowGroup(group: any, model: any): boolean;
 /**
+ * @param {string} group
+ * @param {Model} model
+ */
+export function shouldShowGroup(group: string, model: Model): boolean;
+/**
+ * Populates a contacts map with the given contact, categorizing it into appropriate groups.
  * @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;
 /**
- * @param {RosterContact} contact1
- * @param {RosterContact} contact2
+ * @param {RosterContact|XMPPStatus} contact1
+ * @param {RosterContact|XMPPStatus} contact2
  * @returns {(-1|0|1)}
  */
-export function contactsComparator(contact1: RosterContact, contact2: RosterContact): (-1 | 0 | 1);
-export function groupsComparator(a: any, b: any): 0 | 1 | -1;
+export function contactsComparator(contact1: RosterContact | XMPPStatus, contact2: RosterContact | XMPPStatus): (-1 | 0 | 1);
+/**
+ * @param {string} a
+ * @param {string} b
+ */
+export function groupsComparator(a: string, b: string): 0 | 1 | -1;
 export function getGroupsAutoCompleteList(): any[];
 export function getJIDsAutoCompleteList(): any[];
 /**
@@ -43,4 +59,5 @@ export function getNamesAutoCompleteList(query: string): Promise<{
 export type Model = import("@converse/skeletor").Model;
 export type RosterContact = import("@converse/headless").RosterContact;
 export type RosterContacts = import("@converse/headless").RosterContacts;
+import { XMPPStatus } from "@converse/headless";
 //# sourceMappingURL=utils.d.ts.map