Ver Fonte

Add a disco browser component and use it to show server info

Allow any disco entity to be inspected
JC Brand há 1 mês atrás
pai
commit
7a3ac42d14

+ 1 - 0
CHANGES.md

@@ -8,6 +8,7 @@
 - Properly handle OGP metadata that doesn't have an image
 - Fix TypeError which prevents logging out
 - Fix auto zoom in when input message in ios safari
+- Add a service discovery browser to the settings modal
 
 ## 11.0.0 (2025-05-21)
 

+ 5 - 3
karma.conf.js

@@ -59,7 +59,6 @@ module.exports = function(config) {
       { pattern: "src/headless/shared/settings/tests/settings.js", type: 'module' },
       { pattern: "src/headless/tests/converse.js", type: 'module' },
       { pattern: "src/headless/tests/eventemitter.js", type: 'module' },
-      { pattern: "src/shared/modals/tests/user-details-modal.js", type: 'module' },
       { pattern: "src/plugins/adhoc-views/tests/adhoc.js", type: 'module' },
       { pattern: "src/plugins/bookmark-views/tests/bookmarks-list.js", type: 'module' },
       { pattern: "src/plugins/bookmark-views/tests/bookmarks.js", type: 'module' },
@@ -88,6 +87,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/chatview/tests/xss.js", type: 'module' },
       { pattern: "src/plugins/controlbox/tests/controlbox.js", type: 'module' },
       { pattern: "src/plugins/controlbox/tests/login.js", type: 'module' },
+      { pattern: "src/plugins/disco-views/tests/disco-browser.js", type: 'module' },
       { pattern: "src/plugins/headlines-view/tests/headline.js", type: 'module' },
       { pattern: "src/plugins/mam-views/tests/mam.js", type: 'module' },
       { pattern: "src/plugins/mam-views/tests/placeholder.js", type: 'module' },
@@ -141,16 +141,18 @@ module.exports = function(config) {
       { pattern: "src/plugins/profile/tests/status.js", type: 'module' },
       { pattern: "src/plugins/push/tests/push.js", type: 'module' },
       { pattern: "src/plugins/register/tests/register.js", type: 'module' },
-      { pattern: "src/plugins/roomslist/tests/roomslist.js", type: 'module' },
       { pattern: "src/plugins/roomslist/tests/grouplists.js", type: 'module' },
+      { pattern: "src/plugins/roomslist/tests/roomslist.js", type: 'module' },
       { pattern: "src/plugins/rootview/tests/root.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/add-contact-modal.js", type: 'module' },
+      { pattern: "src/plugins/rosterview/tests/blocklist.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/new-chat-modal.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/presence.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/protocol.js", type: 'module' },
-      { pattern: "src/plugins/rosterview/tests/roster.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/requesting_contacts.js", type: 'module' },
+      { pattern: "src/plugins/rosterview/tests/roster.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/unsaved-contacts.js", type: 'module' },
+      { pattern: "src/shared/modals/tests/user-details-modal.js", type: 'module' },
       { pattern: "src/utils/tests/url.js", type: 'module' },
 
       // For some reason this test causes issues when its run earlier

+ 1 - 0
src/headless/plugins/disco/entity.js

@@ -154,6 +154,7 @@ class DiscoEntity extends Model {
                 if (e.message !== 'item-not-found') {
                     log.error(`Error querying disco#info for ${this.get('jid')}: ${e.message}`);
                 }
+                this.save({ error: e.message });
                 this.waitUntilFeaturesDiscovered.resolve(e);
                 this.waitUntilItemsFetched.resolve(e);
             } else {

+ 2 - 1
src/index.js

@@ -22,10 +22,11 @@ import "./plugins/adhoc-views/index.js";    // Views for XEP-0050 Ad-Hoc command
 import "./plugins/bookmark-views/index.js"; // Views for XEP-0048 Bookmarks
 import "./plugins/chatview/index.js";       // Renders standalone chat boxes for single user chat
 import "./plugins/controlbox/index.js";     // The control box
+import "./plugins/disco-views/index.js";    // Adds a service discovery browser component
 import "./plugins/headlines-view/index.js";
 import "./plugins/mam-views/index.js";
-import "./plugins/muc-views/index.js";      // Views related to MUC
 import "./plugins/minimize/index.js";       // Allows chat boxes to be minimized
+import "./plugins/muc-views/index.js";      // Views related to MUC
 import "./plugins/notifications/index.js";
 import "./plugins/profile/index.js";
 import "./plugins/omemo/index.js";

+ 92 - 0
src/plugins/disco-views/disco-browser.js

@@ -0,0 +1,92 @@
+import { html } from 'lit';
+import { _converse, api } from '@converse/headless';
+import { __ } from 'i18n';
+import { CustomElement } from 'shared/components/element';
+import tplDiscoBrowser from './templates/disco-browser.js';
+
+import './styles/disco-browser.scss';
+
+class DiscoBrowser extends CustomElement {
+    static get properties() {
+        return {
+            _entity_jids: { type: Array, state: true },
+        };
+    }
+
+    constructor() {
+        super();
+        this._entity_jids = [_converse.session.get('domain')];
+    }
+
+    render() {
+        return tplDiscoBrowser(this);
+    }
+
+    /**
+     * @param {MouseEvent} ev
+     * @param {number} index
+     */
+    handleBreadcrumbClick(ev, index) {
+        ev.preventDefault();
+        // Update the _entity_jids array to only include up to the clicked index
+        this._entity_jids = [...this._entity_jids.slice(0, index + 1)];
+    }
+
+    /**
+     * @param {SubmitEvent} ev
+     */
+    queryEntity(ev) {
+        ev.preventDefault();
+        debugger;
+        const form = /** @type {HTMLFormElement} */ (ev.target);
+        const data = new FormData(form);
+        this._entity_jids = [/** @type {string} */ (data.get('entity_jid')).trim()];
+    }
+
+    /**
+     * @param {import('@converse/headless/types/plugins/disco/entity').default} i
+     */
+    renderItem(i) {
+        return html`${i.get('name') ? `${i.get('name')} <${i.get('jid')}>` : `${i.get('jid')}`}`;
+    }
+
+    /**
+     * @param {MouseEvent} ev
+     * @param {import('@converse/headless/types/plugins/disco/entity').default} identity
+     */
+    addEntityJID(ev, identity) {
+        ev.preventDefault();
+        this._entity_jids = [...this._entity_jids, identity.get('jid')];
+    }
+
+    async getDiscoInfo() {
+        const entity_jid = this._entity_jids[this._entity_jids.length - 1];
+        const entity = await api.disco.entities.get(entity_jid, true);
+        await entity.waitUntilItemsFetched;
+        const error = entity.get('error');
+        if (error) {
+            if (['item-not-found', 'remote-server-not-found'].includes(error)) {
+                return {
+                    error: __('No service found with that XMPP address'),
+                };
+            } else {
+                return {
+                    error: __('Error: %1$s', error),
+                };
+            }
+        }
+        const features = entity.features?.map((f) => f.get('var')) || [];
+        const identities = entity.identities || [];
+        const item_jids = entity.get('items') || [];
+        const items = await Promise.all(item_jids.map(/** @param {string} jid */ (jid) => api.disco.entities.get(jid)));
+        return {
+            features: features.toSorted?.() || features,
+            identities,
+            items,
+        };
+    }
+}
+
+api.elements.define('converse-disco-browser', DiscoBrowser);
+
+export default DiscoBrowser;

+ 16 - 0
src/plugins/disco-views/index.js

@@ -0,0 +1,16 @@
+/**
+ * @copyright The Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import { converse } from '@converse/headless';
+import './disco-browser.js';
+
+converse.plugins.add('converse-disco-views', {
+    dependencies: ['converse-disco'],
+
+    enabled() {
+        return true;
+    },
+
+    initialize () {},
+});

+ 13 - 0
src/plugins/disco-views/styles/disco-browser.scss

@@ -0,0 +1,13 @@
+converse-disco-browser {
+    .items-list {
+        .list-item {
+            padding-inline-start: 0.5em !important;
+            padding-inline-end: 0.5em !important;
+        }
+        &.features {
+            .list-item {
+                padding: 0.2em 0;
+            }
+        }
+    }
+}

+ 110 - 0
src/plugins/disco-views/templates/disco-browser.js

@@ -0,0 +1,110 @@
+import { html } from 'lit';
+import { until } from 'lit/directives/until.js';
+import { _converse } from '@converse/headless';
+import { __ } from 'i18n';
+import { getJIDsAutoCompleteList } from 'plugins/rosterview/utils';
+
+/**
+ * @param {import('../disco-browser').default} el
+ */
+export default (el) => {
+    const disco = el.getDiscoInfo();
+    const input_jid = el._entity_jids[el._entity_jids.length - 1];
+    return html` <h4 class="mt-3 text-center">${__('Discover Services')}</h4>
+        <form class="mt-2 mb-3" @submit="${(ev) => el.queryEntity(ev)}">
+            <div class="d-flex w-100">
+                <converse-autocomplete
+                    .getAutoCompleteList="${getJIDsAutoCompleteList}"
+                    class="w-100"
+                    min_chars="2"
+                    name="entity_jid"
+                    position="below"
+                    required
+                    value="${input_jid}"
+                ></converse-autocomplete>
+                <button type="submit" class="btn btn-outline-secondary">${__('Inspect')}</button>
+            </div>
+        </form>
+
+        <nav aria-label="breadcrumb">
+            <ol class="breadcrumb">
+                <li>${__('Service')}:&nbsp;</li>
+                ${el._entity_jids.map(
+                    (jid, index) => html`
+                        <li class="breadcrumb-item">
+                            <a href="#" @click="${(ev) => el.handleBreadcrumbClick(ev, index)}">${jid}</a>
+                        </li>
+                    `
+                )}
+            </ol>
+        </nav>
+
+        ${until(
+            disco.then(() => ''),
+            html`<converse-spinner></converse-spinner>`
+        )}
+        ${until(
+            disco.then(({ error }) => (error ? html`<div class="alert alert-danger" role="alert">${error}</div>` : '')),
+            ''
+        )}
+
+        <div class="container">
+            ${until(
+                disco.then(({ identities }) =>
+                    identities?.length
+                        ? html`<h5 class="mt-3">${__('Identities')}</h5>
+                              <ul class="items-list identities">
+                                  ${identities.map(
+                                      (i) =>
+                                          html`<li class="list-item">
+                                              <ul class="list-unstyled">
+                                                  ${i.get('name')
+                                                      ? html`<li><strong>${__('Name')}:</strong> ${i.get('name')}</li>`
+                                                      : ''}
+                                                  ${i.get('type')
+                                                      ? html`<li><strong>${__('Type')}:</strong> ${i.get('type')}</li>`
+                                                      : ''}
+                                                  ${i.get('category')
+                                                      ? html`<li>
+                                                            <strong>${__('Category')}:</strong> ${i.get('category')}
+                                                        </li>`
+                                                      : ''}
+                                              </ul>
+                                          </li>`
+                                  )}
+                              </ul>`
+                        : ''
+                ),
+                ''
+            )}
+            ${until(
+                disco.then(({ items }) =>
+                    items?.length
+                        ? html`<h5 class="mt-3">${__('Items')}</h5>
+                              <ul class="items-list disco-items">
+                                  ${items.map(
+                                      (i) =>
+                                          html`<li class="list-item">
+                                              <a @click="${(ev) => el.addEntityJID(ev, i)}">${el.renderItem(i)}</a>
+                                          </li>`
+                                  )}
+                              </ul>`
+                        : ''
+                ),
+                ''
+            )}
+            ${until(
+                disco.then(({ features }) =>
+                    features?.length
+                        ? html`
+                              <h5 class="mt-3">${__('Features')}</h5>
+                              <ul class="items-list features">
+                                  ${features.map((f) => html`<li class="list-item">${f}</li>`)}
+                              </ul>
+                          `
+                        : ''
+                ),
+                ''
+            )}
+        </div>`;
+};

+ 221 - 0
src/plugins/disco-views/tests/disco-browser.js

@@ -0,0 +1,221 @@
+const { u } = converse.env;
+
+fdescribe('DiscoBrowser', function () {
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    it(
+        'initializes with the session domain as the first entity',
+        mock.initConverse([], {}, async function (_converse) {
+            const { api } = _converse;
+            await mock.openControlBox(_converse);
+            const cbview = _converse.chatboxviews.get('controlbox');
+            cbview.querySelector('a.show-client-info')?.click();
+            const modal = api.modal.get('converse-user-settings-modal');
+            modal.tab = 'disco';
+            await u.waitUntil(() => modal.querySelector('converse-disco-browser'));
+            const el = modal.querySelector('converse-disco-browser');
+            expect(el._entity_jids).toEqual([_converse.session.get('domain')]);
+        })
+    );
+
+    it(
+        'returns an error message for item-not-found errors',
+        mock.initConverse([], {}, async function (_converse) {
+            const { api } = _converse;
+            await api.modal.show('converse-user-settings-modal');
+            const modal = api.modal.get('converse-user-settings-modal');
+            modal.tab = 'disco';
+            await u.waitUntil(() => modal.querySelector('converse-disco-browser'));
+            const el = modal.querySelector('converse-disco-browser');
+            const input = el.querySelector('converse-autocomplete[name="entity_jid"] input');
+            input.value = 'nonexistent.domain';
+
+            const connection = api.connection.get();
+            while (connection.IQ_stanzas.length) connection.IQ_stanzas.pop();
+
+            const form = el.querySelector('form');
+            const submitEvent = new Event('submit', { bubbles: true });
+            form.dispatchEvent(submitEvent);
+
+            const sent = await u.waitUntil(() =>
+                connection.IQ_stanzas.filter(
+                    (iq) =>
+                        iq.getAttribute('type') === 'get' &&
+                        iq.querySelector('query[xmlns="http://jabber.org/protocol/disco#info"]')
+                ).pop()
+            );
+
+            connection._dataRecv(
+                mock.createRequest(
+                    stx`<iq from="nonexistent.domain"
+                        to="${_converse.session.get('jid')}"
+                        id="${sent.getAttribute('id')}"
+                        type="error"
+                        xmlns="jabber:client">
+                    <error type="cancel">
+                        <item-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+                    </error>
+                </iq>`
+                )
+            );
+
+            const alert = await u.waitUntil(() => modal.querySelector('.alert-danger'));
+            expect(alert.textContent).toBe('No service found with that XMPP address');
+        })
+    );
+
+    it(
+        'returns features, identities and items when successful',
+        mock.initConverse(['discoInitialized'], {}, async function (_converse) {
+            const { api } = _converse;
+            await api.modal.show('converse-user-settings-modal');
+            const modal = api.modal.get('converse-user-settings-modal');
+            modal.tab = 'disco';
+            await u.waitUntil(() => modal.querySelector('converse-disco-browser'));
+            const connection = api.connection.get();
+            const { IQ_stanzas, IQ_ids } = connection;
+
+            await u.waitUntil(function () {
+                return (
+                    IQ_stanzas.filter(function (iq) {
+                        return iq.querySelector(
+                            'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]'
+                        );
+                    }).length > 0
+                );
+            });
+            let stanza = IQ_stanzas.find(function (iq) {
+                return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]');
+            });
+            const info_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+            stanza = stx`
+            <iq xmlns="jabber:client"
+                type='result'
+                from='montague.lit'
+                to='romeo@montague.lit/orchard'
+                id='${info_IQ_id}'>
+                <query xmlns='http://jabber.org/protocol/disco#info'>
+                    <identity category='server' type='im'/>
+                    <identity category='conference' type='text' name='Play-Specific Chatrooms'/>
+                    <identity category='directory' type='chatroom' name='Play-Specific Chatrooms'/>
+                    <feature var='http://jabber.org/protocol/disco#info'/>
+                    <feature var='http://jabber.org/protocol/disco#items'/>
+                    <feature var='jabber:iq:register'/>
+                    <feature var='jabber:iq:time'/>
+                    <feature var='jabber:iq:version'/>
+                </query>
+            </iq>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+            await u.waitUntil(() => {
+                // Converse.js sees that the entity has a disco#items feature,
+                // so it will make a query for it.
+                return (
+                    IQ_stanzas.filter((iq) => iq.querySelector('query[xmlns="http://jabber.org/protocol/disco#items"]'))
+                        .length > 0
+                );
+            });
+
+            stanza = IQ_stanzas.find((iq) =>
+                iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]')
+            );
+            const items_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+
+            _converse.api.connection.get()._dataRecv(
+                mock.createRequest(stx`
+            <iq xmlns="jabber:client"
+                type='result'
+                from='montague.lit'
+                to='romeo@montague.lit/orchard'
+                id='${items_IQ_id}'>
+                <query xmlns='http://jabber.org/protocol/disco#items'>
+                    <item jid='people.shakespeare.lit' name='Directory of Characters'/>
+                    <item jid='plays.shakespeare.lit' name='Play-Specific Chatrooms'/>
+                    <item jid='words.shakespeare.lit' name='Gateway to Marlowe IM'/>
+                    <item jid='montague.lit' node='books' name='Books by and about Shakespeare'/>
+                    <item node='montague.lit' name='Wear your literary taste with pride'/>
+                    <item jid='montague.lit' node='music' name='Music from the time of Shakespeare'/>
+                </query>
+            </iq>`)
+            );
+
+            await u.waitUntil(() => modal.querySelector('.items-list'));
+
+            // Verify identities are rendered
+            const identities = modal.querySelectorAll('#server-tabpanel .identities .list-item');
+            expect(identities.length).toBe(3); // server, conference, directory identities
+            expect(identities[0].textContent).toContain('Category: server');
+            expect(identities[0].textContent).toContain('Type: im');
+            expect(identities[1].textContent).toContain('Name: Play-Specific Chatrooms');
+            expect(identities[1].textContent).toContain('Category: conference');
+            expect(identities[1].textContent).toContain('Type: text');
+            expect(identities[2].textContent).toContain('Name: Play-Specific Chatrooms');
+            expect(identities[2].textContent).toContain('Category: directory');
+            expect(identities[2].textContent).toContain('Type: chatroom');
+
+            // Verify features are rendered
+            const features = modal.querySelectorAll('#server-tabpanel .items-list.features li');
+            expect(features.length).toBe(5);
+            expect(features[0].textContent).toBe('http://jabber.org/protocol/disco#info');
+            expect(features[1].textContent).toBe('http://jabber.org/protocol/disco#items');
+            expect(features[2].textContent).toBe('jabber:iq:register');
+            expect(features[3].textContent).toBe('jabber:iq:time');
+            expect(features[4].textContent).toBe('jabber:iq:version');
+
+            // "nodes" are not yet supported so not shown
+            const items = modal.querySelectorAll('#server-tabpanel .items-list a');
+            expect(items.length).toBe(3);
+            expect(items[0].textContent).toBe('Directory of Characters <people.shakespeare.lit>');
+            expect(items[1].textContent).toBe('Play-Specific Chatrooms <plays.shakespeare.lit>');
+            expect(items[2].textContent).toBe('Gateway to Marlowe IM <words.shakespeare.lit>');
+            /**
+                expect(items[3].textContent).toBe('Books by and about Shakespeare <montague.lit>');
+                expect(items[4].textContent).toBe('Wear your literary taste with pride <montague.lit>');
+                expect(items[5].textContent).toBe('Music from the time of Shakespeare <montague.lit>');
+            */
+        })
+    );
+
+    it(
+        'updates the _entity_jids array to only include up to the clicked breadcrumb',
+        mock.initConverse([], {}, async function (_converse) {
+            const { api } = _converse;
+            await api.modal.show('converse-user-settings-modal');
+            const modal = api.modal.get('converse-user-settings-modal');
+            modal.tab = 'disco';
+            await u.waitUntil(() => modal.querySelector('converse-disco-browser'));
+            const el = modal.querySelector('converse-disco-browser');
+            el._entity_jids = ['domain1', 'domain2', 'domain3'];
+            const ev = { preventDefault: function () {} };
+            spyOn(ev, 'preventDefault');
+            el.handleBreadcrumbClick(ev, 1);
+            await u.waitUntil(() => el._entity_jids.length === 2);
+            expect(el._entity_jids).toEqual(['domain1', 'domain2']);
+            expect(ev.preventDefault).toHaveBeenCalled();
+        })
+    );
+
+    it(
+        'updates _entity_jids with the submitted JID',
+        mock.initConverse([], {}, async function (_converse) {
+            const { api } = _converse;
+            await api.modal.show('converse-user-settings-modal');
+            const modal = api.modal.get('converse-user-settings-modal');
+            modal.tab = 'disco';
+            await u.waitUntil(() => modal.querySelector('converse-disco-browser'));
+            const el = modal.querySelector('converse-disco-browser');
+
+            const input = el.querySelector('converse-autocomplete[name="entity_jid"] input');
+            input.value = 'new.domain';
+
+            const form = el.querySelector('form');
+            const submitEvent = new Event('submit', { bubbles: true });
+            spyOn(submitEvent, 'preventDefault');
+            form.dispatchEvent(submitEvent);
+
+            await u.waitUntil(() => el._entity_jids[0] === 'new.domain');
+            expect(el._entity_jids).toEqual(['new.domain']);
+            expect(submitEvent.preventDefault).toHaveBeenCalled();
+        })
+    );
+});

+ 1 - 1
src/plugins/profile/index.js

@@ -18,6 +18,6 @@ converse.plugins.add('converse-profile', {
     ],
 
     initialize () {
-        api.settings.extend({ 'show_client_info': true });
+        api.settings.extend({ show_client_info: true });
     },
 });

+ 30 - 45
src/plugins/profile/modals/templates/user-settings.js

@@ -1,33 +1,25 @@
 import DOMPurify from 'dompurify';
 import { html } from 'lit';
-import { until } from 'lit/directives/until.js';
 import { _converse, api } from '@converse/headless';
 import { __ } from 'i18n';
 import { unsafeHTML } from 'lit/directives/unsafe-html.js';
 
-async function getFeatures() {
-    const domain = _converse.session.get('domain');
-    const features = await api.disco.getFeatures(domain);
-    const names = features.map((f) => f.get('var'));
-    return names.toSorted?.() || names;
-}
-
 /**
  * @param {import('../user-settings').default} el
  */
 const tplNavigation = (el) => {
     const i18n_about = __('About');
-    const i18n_server = __('Server');
     const i18n_commands = __('Commands');
-
+    const i18n_services = __('Services');
     const show_client_info = api.settings.get('show_client_info');
     const allow_adhoc_commands = api.settings.get('allow_adhoc_commands');
-    const show_tabs = show_client_info && allow_adhoc_commands;
+    const has_disco_browser = _converse.pluggable.plugins['converse-disco-views']?.enabled(_converse);
+    const show_tabs = (show_client_info ? 1 : 0) + (allow_adhoc_commands ? 1 : 0) + (has_disco_browser ? 1 : 0) >= 2;
     return html`
         ${show_tabs
             ? html`<ul class="nav nav-pills justify-content-center">
                   ${show_client_info
-                      ? html` <li role="presentation" class="nav-item">
+                      ? html`<li role="presentation" class="nav-item">
                             <a
                                 class="nav-link ${el.tab === 'about' ? 'active' : ''}"
                                 id="about-tab"
@@ -42,7 +34,7 @@ const tplNavigation = (el) => {
                         </li>`
                       : ''}
                   ${allow_adhoc_commands
-                      ? html` <li role="presentation" class="nav-item">
+                      ? html`<li role="presentation" class="nav-item">
                             <a
                                 class="nav-link ${el.tab === 'commands' ? 'active' : ''}"
                                 id="commands-tab"
@@ -56,19 +48,21 @@ const tplNavigation = (el) => {
                             >
                         </li>`
                       : ''}
-                  <li role="presentation" class="nav-item">
-                      <a
-                          class="nav-link ${el.tab === 'server' ? 'active' : ''}"
-                          id="server-tab"
-                          href="#server-tabpanel"
-                          aria-controls="server-tabpanel"
-                          role="tab"
-                          data-toggle="tab"
-                          data-name="server"
-                          @click=${(ev) => el.switchTab(ev)}
-                          >${i18n_server}</a
-                      >
-                  </li>
+                  ${has_disco_browser
+                      ? html`<li role="presentation" class="nav-item">
+                            <a
+                                class="nav-link ${el.tab === 'disco' ? 'active' : ''}"
+                                id="server-tab"
+                                href="#server-tabpanel"
+                                aria-controls="server-tabpanel"
+                                role="tab"
+                                data-toggle="tab"
+                                data-name="disco"
+                                @click=${(ev) => el.switchTab(ev)}
+                                >${i18n_services}</a
+                            >
+                        </li>`
+                      : ''}
               </ul>`
             : ''}
     `;
@@ -128,25 +122,16 @@ export default (el) => {
                       </div>
                   `
                 : ''}
-
-            <div
-                class="tab-pane tab-pane--columns ${el.tab === 'server' ? 'active' : ''}"
-                id="server-tabpanel"
-                role="tabpanel"
-                aria-labelledby="server-tab"
-            >
-                <div class="container">
-                    <h5 class="mt-3">${__('Server Features')}</h5>
-                    <ul>
-                        ${until(
-                            getFeatures().then((features) => {
-                                return html`${features.map((f) => html`<li>${f}</li>`)}`;
-                            }),
-                            ''
-                        )}
-                    </ul>
-                </div>
-            </div>
+            ${_converse.pluggable.plugins['converse-disco-views']?.enabled(_converse)
+                ? html` <div
+                      class="tab-pane tab-pane--columns ${el.tab === 'disco' ? 'active' : ''}"
+                      id="server-tabpanel"
+                      role="tabpanel"
+                      aria-labelledby="server-tab"
+                  >
+                      ${el.tab === 'disco' ? html`<converse-disco-browser></converse-disco-browser>` : ''}
+                  </div>`
+                : ''}
         </div>
     `;
 };

+ 1 - 0
src/plugins/roomslist/styles/roomsgroups.scss

@@ -25,6 +25,7 @@
                     display: flex;
                     flex-direction: row;
                     line-height: 1.5em;
+                    height: 2.5em;
                     span {
                         padding-top: 0.25em;
                     }

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

@@ -40,6 +40,7 @@
 
             .roster-group-contacts {
                 .list-item {
+                    height: 2.5em;
                     &:hover {
                         .list-item-action {
                             opacity: 1;

+ 1 - 1
src/shared/components/brand-heading.js

@@ -1,4 +1,4 @@
-import { html } from 'lit/html.js';
+import { html } from 'lit';
 import { api } from '@converse/headless';
 import { CustomElement } from './element.js';
 import './brand-logo.js';

+ 1 - 0
src/shared/constants.js

@@ -11,6 +11,7 @@ export const VIEW_PLUGINS = [
     'converse-chatboxviews',
     'converse-chatview',
     'converse-controlbox',
+    'converse-disco-views',
     'converse-dragresize',
     'converse-fullscreen',
     'converse-headlines-view',

+ 1 - 1
src/shared/styles/index.scss

@@ -34,7 +34,7 @@ $prefix: 'converse-';
     @import "bootstrap/scss/navbar";
     @import "bootstrap/scss/card";
     // @import "bootstrap/scss/accordion";
-    // @import "bootstrap/scss/breadcrumb";
+    @import "bootstrap/scss/breadcrumb";
     // @import "bootstrap/scss/pagination";
     @import "bootstrap/scss/badge";
     @import "bootstrap/scss/alert";

+ 0 - 1
src/shared/styles/lists.scss

@@ -33,7 +33,6 @@
             color: var(--text-color);
             padding: 0.5em 0;
             word-wrap: break-word;
-            height: 2.5em;
 
             &.unread-msgs {
                 font-weight: bold;

+ 42 - 0
src/types/plugins/disco-views/disco-browser.d.ts

@@ -0,0 +1,42 @@
+export default DiscoBrowser;
+declare class DiscoBrowser extends CustomElement {
+    static get properties(): {
+        _entity_jids: {
+            type: ArrayConstructor;
+            state: boolean;
+        };
+    };
+    _entity_jids: any[];
+    render(): import("lit-html").TemplateResult<1>;
+    /**
+     * @param {MouseEvent} ev
+     * @param {number} index
+     */
+    handleBreadcrumbClick(ev: MouseEvent, index: number): void;
+    /**
+     * @param {SubmitEvent} ev
+     */
+    queryEntity(ev: SubmitEvent): void;
+    /**
+     * @param {import('@converse/headless/types/plugins/disco/entity').default} i
+     */
+    renderItem(i: import("@converse/headless/types/plugins/disco/entity").default): import("lit-html").TemplateResult<1>;
+    /**
+     * @param {MouseEvent} ev
+     * @param {import('@converse/headless/types/plugins/disco/entity').default} identity
+     */
+    addEntityJID(ev: MouseEvent, identity: import("@converse/headless/types/plugins/disco/entity").default): void;
+    getDiscoInfo(): Promise<{
+        error: any;
+        features?: undefined;
+        identities?: undefined;
+        items?: undefined;
+    } | {
+        features: any;
+        identities: any;
+        items: any[];
+        error?: undefined;
+    }>;
+}
+import { CustomElement } from 'shared/components/element';
+//# sourceMappingURL=disco-browser.d.ts.map

+ 2 - 0
src/types/plugins/disco-views/index.d.ts

@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=index.d.ts.map

+ 3 - 0
src/types/plugins/disco-views/templates/disco-browser.d.ts

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

+ 26 - 0
src/types/shared/components/disco-browser.d.ts

@@ -0,0 +1,26 @@
+export class DiscoBrowser extends CustomElement {
+    static get properties(): {
+        _entity_jid: {
+            type: StringConstructor;
+            state: boolean;
+        };
+    };
+    _entity_jid: any;
+    render(): import("lit-html").TemplateResult<1>;
+    /**
+     * @param {import('@converse/headless/types/plugins/disco/entity').default} i
+     */
+    renderItem(i: import("@converse/headless/types/plugins/disco/entity").default): import("lit-html").TemplateResult<1>;
+    /**
+     * @param {MouseEvent} ev
+     * @param {import('@converse/headless/types/plugins/disco/entity').default} identity
+     */
+    setEntityJID(ev: MouseEvent, identity: import("@converse/headless/types/plugins/disco/entity").default): void;
+    getDiscoInfo(): Promise<{
+        features: any;
+        identities: any;
+        items: any[];
+    }>;
+}
+import { CustomElement } from './element';
+//# sourceMappingURL=disco-browser.d.ts.map