فهرست منبع

Fixes #515 Add support for XEP-0050 Ad-Hoc commands

JC Brand 5 سال پیش
والد
کامیت
78b60a3bd9

+ 1 - 0
CHANGES.md

@@ -6,6 +6,7 @@
 configuration settings should now be accessed via `_converse.api.settings.get` and not directly on the `_converse` object.
 Soon we'll deprecate the latter, so prepare now.
 
+- #515 Add support for XEP-0050 Ad-Hoc commands
 - #1313: Stylistic improvements to the send button
 - #1490: Busy-loop when fetching registration form fails
 - #1535: Add option to destroy a MUC

+ 1 - 0
sass/_core.scss

@@ -200,6 +200,7 @@ body.converse-fullscreen {
     .dropdown-item {
       padding: 0.5rem 1rem;
       .fa {
+        width: 1.25em;
         margin-right: 0.75rem;
       }
       &:active, &.selected {

+ 216 - 0
src/components/adhoc-commands.js

@@ -0,0 +1,216 @@
+import "./autocomplete.js"
+import { __ } from '@converse/headless/i18n';
+import { CustomElement } from './element.js';
+import { api } from "@converse/headless/converse-core";
+import { html } from "lit-html";
+import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
+import log from "@converse/headless/log";
+import sizzle from "sizzle";
+
+const { Strophe, $iq } = window.converse.env;
+const u = window.converse.env.utils;
+
+const i18n_hide = __('Hide');
+const i18n_choose_service = __('On which entity do you want to run commands?');
+const i18n_choose_service_instructions = __(
+    'Certain XMPP services and entities allow privileged users to execute ad-hoc commands on them.');
+const i18n_commands_found = __('Commands found');
+const i18n_fetch_commands = __('List available commands');
+const i18n_jid_placeholder = __('XMPP Address');
+const i18n_no_commands_found = __('No commands found');
+const i18n_run = __('Execute');
+
+
+const tpl_command_form = (o, command) => html`
+    <form @submit=${o.runCommand}>
+        ${ command.alert ? html`<div class="alert alert-${command.alert_type}" role="alert">${command.alert}</div>` : '' }
+        <fieldset class="form-group">
+            <input type="hidden" name="command_node" value="${command.node}"/>
+            <input type="hidden" name="command_jid" value="${command.jid}"/>
+
+            <p class="form-help">${command.instructions}</p>
+            <!-- Fields are generated internally, with xForm2webForm -->
+            ${ command.fields.map(field =>  unsafeHTML(field)) }
+        </fieldset>
+        <fieldset>
+            <input type="submit" class="btn btn-primary" value="${i18n_run}">
+            <input type="button" class="btn btn-secondary button-cancel" value="${i18n_hide}" @click=${o.hideCommandForm}>
+        </fieldset>
+    </form>
+`;
+
+
+const tpl_command = (o, command) => html`
+    <li class="room-item list-group-item">
+        <div class="available-chatroom d-flex flex-row">
+            <a class="open-room available-room w-100"
+               @click=${o.toggleCommandForm}
+               data-command-node="${command.node}"
+               data-command-jid="${command.jid}"
+               data-command-name="${command.name}"
+               title="${command.name}"
+               href="#">${command.name || command.jid}</a>
+        </div>
+        ${ command.node === o.showform ? tpl_command_form(o, command) : '' }
+    </li>
+`;
+
+
+async function getAutoCompleteList () {
+    const models = [...(await api.rooms.get()), ...(await api.contacts.get())];
+    const jids = [...new Set(models.map(o => Strophe.getDomainFromJid(o.get('jid'))))];
+    return jids;
+}
+
+const tpl_adhoc = (o) => html`
+    <form class="converse-form" @submit=${o.fetchCommands}>
+        <fieldset class="form-group">
+            <label>
+                ${i18n_choose_service}
+                <p class="form-help">${i18n_choose_service_instructions}</p>
+                <converse-autocomplete
+                    .getAutoCompleteList="${getAutoCompleteList}"
+                    placeholder="${i18n_jid_placeholder}"
+                    name="jid"/>
+            </label>
+        </fieldset>
+        <fieldset class="form-group">
+            <input type="submit" class="btn btn-primary" value="${i18n_fetch_commands}">
+        </fieldset>
+        ${ o.view === 'list-commands' ? html`
+        <fieldset class="form-group">
+            <ul class="list-group">
+                <li class="list-group-item active">${ o.commands.length ? i18n_commands_found : i18n_no_commands_found }:</li>
+                ${ o.commands.map(cmd => tpl_command(o, cmd)) }
+            </ul>
+        </fieldset>`
+        : '' }
+
+    </form>
+`;
+
+
+async function fetchCommandForm (command) {
+    const node = command.node;
+    const jid = command.jid;
+    const stanza = $iq({
+        'type': 'set',
+        'to': jid
+    }).c('command', {
+        'xmlns': Strophe.NS.ADHOC,
+        'node': node,
+        'action': 'execute'
+    });
+    try {
+        const iq = await api.sendIQ(stanza);
+        const cmd_el = sizzle(`command[xmlns="${Strophe.NS.ADHOC}"]`, iq).pop();
+        command.sessionid = cmd_el.getAttribute('sessionid');
+        command.instructions = sizzle('x[type="form"][xmlns="jabber:x:data"] instructions', cmd_el).pop()?.textContent;
+        command.fields = sizzle('x[type="form"][xmlns="jabber:x:data"] field', cmd_el)
+            .map(f => u.xForm2webForm(f, cmd_el));
+
+    } catch (e) {
+        if (e === null) {
+            log.error(`Error: timeout while trying to execute command for ${jid}`);
+        } else {
+            log.error(`Error while trying to execute command for ${jid}`);
+            log.error(e);
+        }
+        command.fields = [];
+    }
+}
+
+
+export class AdHocCommands extends CustomElement {
+
+    static get properties () {
+        return {
+            'view': { type: String },
+            'showform': { type: String },
+            'nonce': { type: String } // Used to force re-rendering
+        }
+    }
+
+    constructor () {
+        super();
+        this.view = 'choose-service';
+        this.showform = '';
+        this.commands = [];
+    }
+
+    render () {
+        return tpl_adhoc({
+            'commands': this.commands,
+            'fetchCommands': ev => this.fetchCommands(ev),
+            'hideCommandForm': ev => this.hideCommandForm(ev),
+            'runCommand': ev => this.runCommand(ev),
+            'showform': this.showform,
+            'toggleCommandForm': ev => this.toggleCommandForm(ev),
+            'view': this.view,
+        });
+    }
+
+    async fetchCommands (ev) {
+        ev.preventDefault();
+        const form_data = new FormData(ev.target);
+        const jid = form_data.get('jid').trim();
+        if (await api.disco.supports(Strophe.NS.ADHOC, jid)) {
+            this.commands = await api.adhoc.getCommands(jid);
+            this.view = 'list-commands';
+        }
+    }
+
+    async toggleCommandForm (ev) {
+        ev.preventDefault();
+        const node = ev.target.getAttribute('data-command-node');
+        const cmd = this.commands.filter(c => c.node === node)[0];
+        this.showform !== node && await fetchCommandForm(cmd);
+        this.showform = node;
+    }
+
+    hideCommandForm (ev) {
+        ev.preventDefault();
+        this.showform = ''
+    }
+
+    async runCommand (ev) {
+        ev.preventDefault();
+        const form_data = new FormData(ev.target);
+        const jid = form_data.get('command_jid').trim();
+        const node = form_data.get('command_node').trim();
+
+        const cmd = this.commands.filter(c => c.node === node)[0];
+        const inputs = sizzle(':input:not([type=button]):not([type=submit])', ev.target);
+        const configArray = inputs
+            .filter(i => !['command_jid', 'command_node'].includes(i.getAttribute('name')))
+            .map(u.webForm2xForm);
+
+        const iq = $iq({to: jid, type: "set"})
+            .c("command", {
+                'sessionid': cmd.session,
+                'node': cmd.node,
+                'xmlns': Strophe.NS.ADHOC
+            }).c("x", {xmlns: Strophe.NS.XFORM, type: "submit"});
+        configArray.forEach(node => iq.cnode(node).up());
+
+        let result;
+        try {
+            result = await api.sendIQ(iq);
+        } catch (e) {
+            cmd.alert_type = 'danger';
+            cmd.alert = __('Sorry, an error occurred while trying to execute the command. See the developer console for details');
+            log.error('Error while trying to execute an ad-hoc command');
+            log.error(e);
+        }
+
+        if (result) {
+            cmd.alert = result.querySelector('note')?.textContent;
+        } else {
+            cmd.alert = 'Done';
+        }
+        cmd.alert_type = 'primary';
+        this.nonce = u.getUniqueId();
+    }
+}
+
+window.customElements.define('converse-adhoc-commands', AdHocCommands);

+ 6 - 29
src/converse-muc-views.js

@@ -12,6 +12,7 @@ import { debounce, head, isString, isUndefined } from "lodash";
 import { BootstrapModal } from "./converse-modal.js";
 import { render } from "lit-html";
 import { __ } from '@converse/headless/i18n';
+import RoomDetailsModal from 'modals/muc-details.js';
 import converse from "@converse/headless/converse-core";
 import log from "@converse/headless/log";
 import st from "@converse/headless/utils/stanza";
@@ -19,7 +20,6 @@ import tpl_add_chatroom_modal from "templates/add_chatroom_modal.js";
 import tpl_chatroom from "templates/chatroom.js";
 import tpl_chatroom_bottom_panel from "templates/chatroom_bottom_panel.html";
 import tpl_chatroom_destroyed from "templates/chatroom_destroyed.html";
-import tpl_chatroom_details_modal from "templates/chatroom_details_modal.js";
 import tpl_chatroom_disconnect from "templates/chatroom_disconnect.html";
 import tpl_chatroom_head from "templates/chatroom_head.js";
 import tpl_chatroom_nickname_form from "templates/chatroom_nickname_form.html";
@@ -610,30 +610,6 @@ converse.plugins.add('converse-muc-views', {
         });
 
 
-        _converse.RoomDetailsModal = BootstrapModal.extend({
-            id: "room-details-modal",
-
-            initialize () {
-                BootstrapModal.prototype.initialize.apply(this, arguments);
-                this.listenTo(this.model, 'change', this.render);
-                this.listenTo(this.model.features, 'change', this.render);
-                this.listenTo(this.model.occupants, 'add', this.render);
-                this.listenTo(this.model.occupants, 'change', this.render);
-            },
-
-            toHTML () {
-                return tpl_chatroom_details_modal(Object.assign(
-                    this.model.toJSON(), {
-                        'config': this.model.config.toJSON(),
-                        'display_name': __('Groupchat info for %1$s', this.model.getDisplayName()),
-                        'features': this.model.features.toJSON(),
-                        'num_occupants': this.model.occupants.length,
-                    })
-                );
-            }
-        });
-
-
         /**
          * NativeView which renders a groupchat, based upon
          * { @link _converse.ChatBoxView } for normal one-on-one chat boxes.
@@ -1080,7 +1056,7 @@ converse.plugins.add('converse-muc-views', {
             showRoomDetailsModal (ev) {
                 ev.preventDefault();
                 if (this.model.room_details_modal === undefined) {
-                    this.model.room_details_modal = new _converse.RoomDetailsModal({'model': this.model});
+                    this.model.room_details_modal = new RoomDetailsModal({'model': this.model});
                 }
                 this.model.room_details_modal.show(ev);
             },
@@ -1106,20 +1082,21 @@ converse.plugins.add('converse-muc-views', {
 
             /**
              * Returns a list of objects which represent buttons for the groupchat header.
-             * @async
              * @emits _converse#getHeadingButtons
              * @private
              * @method _converse.ChatRoomView#getHeadingButtons
              */
             getHeadingButtons (subject_hidden) {
-                const buttons = [{
+                const buttons = [];
+                buttons.push({
                     'i18n_text': __('Details'),
                     'i18n_title': __('Show more information about this groupchat'),
                     'handler': ev => this.showRoomDetailsModal(ev),
                     'a_class': 'show-room-details-modal',
                     'icon_class': 'fa-info-circle',
                     'name': 'details'
-                }];
+                });
+
                 if (this.model.getOwnAffiliation() === 'owner') {
                     buttons.push({
                         'i18n_text': __('Configure'),

+ 10 - 21
src/converse-profile.js

@@ -7,12 +7,12 @@ import "@converse/headless/converse-status";
 import "@converse/headless/converse-vcard";
 import "converse-modal";
 import { BootstrapModal } from "./converse-modal.js";
+import UserSettingsModal from "modals/user-settings";
 import bootstrap from "bootstrap.native";
 import converse from "@converse/headless/converse-core";
 import log from "@converse/headless/log";
 import sizzle from 'sizzle';
 import tpl_chat_status_modal from "templates/chat_status_modal";
-import tpl_client_info_modal from "templates/client_info_modal";
 import tpl_profile from "templates/profile.js";
 import tpl_profile_modal from "templates/profile_modal";
 
@@ -182,26 +182,11 @@ converse.plugins.add('converse-profile', {
             }
         });
 
-        _converse.ClientInfoModal = BootstrapModal.extend({
-            id: "converse-client-info-modal",
-
-            toHTML () {
-                return tpl_client_info_modal(
-                    Object.assign(
-                        this.model.toJSON(),
-                        this.model.vcard.toJSON(),
-                        { 'version_name': _converse.VERSION_NAME }
-                    )
-                );
-            }
-        });
-
         _converse.XMPPStatusView = _converse.ViewWithAvatar.extend({
             tagName: "div",
             events: {
                 "click a.show-profile": "showProfileModal",
                 "click a.change-status": "showStatusChangeModal",
-                "click .show-client-info": "showClientInfoModal",
                 "click .logout": "logOut"
             },
 
@@ -218,8 +203,9 @@ converse.plugins.add('converse-profile', {
                     _converse,
                     chat_status,
                     'fullname': this.model.vcard.get('fullname') || _converse.bare_jid,
+                    "showUserSettingsModal": ev => this.showUserSettingsModal(ev),
                     'status_message': this.model.get('status_message') ||
-                                        __("I am %1$s", this.getPrettyStatus(chat_status))
+                                        __("I am %1$s", this.getPrettyStatus(chat_status)),
                 }));
             },
 
@@ -228,6 +214,7 @@ converse.plugins.add('converse-profile', {
             },
 
             showProfileModal (ev) {
+                ev.preventDefault();
                 if (this.profile_modal === undefined) {
                     this.profile_modal = new _converse.ProfileModal({model: this.model});
                 }
@@ -235,17 +222,19 @@ converse.plugins.add('converse-profile', {
             },
 
             showStatusChangeModal (ev) {
+                ev.preventDefault();
                 if (this.status_modal === undefined) {
                     this.status_modal = new _converse.ChatStatusModal({model: this.model});
                 }
                 this.status_modal.show(ev);
             },
 
-            showClientInfoModal(ev) {
-                if (this.client_info_modal === undefined) {
-                    this.client_info_modal = new _converse.ClientInfoModal({model: this.model});
+            showUserSettingsModal(ev) {
+                ev.preventDefault();
+                if (this.user_settings_modal === undefined) {
+                    this.user_settings_modal = new UserSettingsModal({model: this.model, _converse});
                 }
-                this.client_info_modal.show(ev);
+                this.user_settings_modal.show(ev);
             },
 
             logOut (ev) {

+ 5 - 3
src/converse-roomslist.js

@@ -7,11 +7,13 @@
  * @license Mozilla Public License (MPLv2)
  */
 import "@converse/headless/converse-muc";
+import RoomDetailsModal from 'modals/muc-details.js';
+import converse from "@converse/headless/converse-core";
+import tpl_rooms_list from "templates/rooms_list.js";
 import { Model } from 'skeletor.js/src/model.js';
 import { View } from 'skeletor.js/src/view.js';
 import { __ } from '@converse/headless/i18n';
-import converse from "@converse/headless/converse-core";
-import tpl_rooms_list from "templates/rooms_list.js";
+
 
 const { Strophe } = converse.env;
 const u = converse.env.utils;
@@ -110,7 +112,7 @@ converse.plugins.add('converse-roomslist', {
                 const room = _converse.chatboxes.get(jid);
                 ev.preventDefault();
                 if (room.room_details_modal === undefined) {
-                    room.room_details_modal = new _converse.RoomDetailsModal({'model': room});
+                    room.room_details_modal = new RoomDetailsModal({'model': room});
                 }
                 room.room_details_modal.show(ev);
             },

+ 5 - 3
src/headless/converse-core.js

@@ -4,7 +4,7 @@
  * @license Mozilla Public License (MPLv2)
  */
 import { __, i18n } from './i18n';
-import { assignIn, debounce, invoke, isFunction, isObject, isString, pick } from 'lodash';
+import { assignIn, debounce, invoke, isElement, isFunction, isObject, isString, pick } from 'lodash';
 import { Collection } from "skeletor.js/src/collection";
 import { Events } from 'skeletor.js/src/events.js';
 import { Model } from 'skeletor.js/src/model.js';
@@ -97,6 +97,7 @@ const PROMISES = [
 // These are just the @converse/headless plugins, for the full converse,
 // the other plugins are whitelisted in src/converse.js
 const CORE_PLUGINS = [
+    'converse-adhoc',
     'converse-bookmarks',
     'converse-bosh',
     'converse-caps',
@@ -307,7 +308,7 @@ function initUserSettings () {
  * @namespace _converse.api
  * @memberOf _converse
  */
-const api = _converse.api = {
+export const api = _converse.api = {
     /**
      * This grouping collects API functions related to the XMPP connection.
      *
@@ -887,7 +888,8 @@ const api = _converse.api = {
             promise = new Promise((resolve, reject) => _converse.connection.sendIQ(stanza, resolve, reject, timeout));
             promise.catch(e => {
                 if (e === null) {
-                    throw new TimeoutError(`Timeout error after ${timeout}ms for the following IQ stanza: ${stanza}`);
+                    const el = isElement(stanza) ? stanza : stanza.nodeTree;
+                    throw new TimeoutError(`Timeout error after ${timeout}ms for the following IQ stanza: ${el}`);
                 }
             });
         } else {

+ 1 - 0
src/headless/headless.js

@@ -2,6 +2,7 @@
  * --------------------
  * Any of the following components may be removed if they're not needed.
  */
+import "./converse-adhoc";       // XEP-0050 Ad Hoc Commands
 import "./converse-bookmarks";   // XEP-0199 XMPP Ping
 import "./converse-bosh";        // XEP-0206 BOSH
 import "./converse-caps";        // XEP-0115 Entity Capabilities

+ 60 - 2
src/modals/muc-commands.js

@@ -1,9 +1,12 @@
 import { BootstrapModal } from "../converse-modal.js";
 import { __ } from '@converse/headless/i18n';
 import { api } from "@converse/headless/converse-core";
+import log from "@converse/headless/log";
+import sizzle from "sizzle";
 import tpl_muc_commands_modal from "../templates/muc_commands_modal.js";
 
-const { Strophe } = window.converse.env;
+const { Strophe, $iq } = window.converse.env;
+const u = window.converse.env.utils;
 
 
 export default BootstrapModal.extend({
@@ -19,8 +22,9 @@ export default BootstrapModal.extend({
     toHTML () {
         return tpl_muc_commands_modal(Object.assign(
             this.model.toJSON(), {
+                'commands': this.commands,
                 'display_name': __('Ad-hoc commands for %1$s', this.model.getDisplayName()),
-                'commands': this.commands
+                'toggleCommandForm': ev => this.toggleCommandForm(ev)
             })
         );
     },
@@ -28,5 +32,59 @@ export default BootstrapModal.extend({
     async getCommands () {
         this.commands = await api.adhoc.getCommands(Strophe.getDomainFromJid(this.model.get('jid')));
         this.render();
+    },
+
+    async toggleCommandForm (ev) {
+        ev.preventDefault();
+        const node = ev.target.getAttribute('data-command-node');
+        this.commands.filter(c => (c.node !== node)).forEach(c => (c.show_form = false));
+        const cmd = this.commands.filter(c => c.node === node)[0];
+        cmd.show_form = !cmd.show_form;
+        cmd.show_form && await this.fetchCommandForm(cmd);
+        this.render();
+    },
+
+    async fetchCommandForm (command) {
+        const node = command.node;
+        const jid = command.jid;
+        const stanza = $iq({
+            'type': 'set',
+            'to': jid
+        }).c('command', {
+            'xmlns': Strophe.NS.ADHOC,
+            'node': node,
+            'action': 'execute'
+        });
+        command.fields;
+        try {
+            const iq = await api.sendIQ(stanza);
+            command.fields = sizzle('field', iq).map(f => u.xForm2webForm(f, iq))
+        } catch (e) {
+            if (e === null) {
+                log.error(`Error: timeout while trying to execute command for ${jid}`);
+            } else {
+                log.error(`Error while trying to execute command for ${jid}`);
+                log.error(e);
+            }
+            command.fields = [];
+        }
+
+        /*
+        <iq xmlns="jabber:client" id="72c21b57-5e9f-4b63-9e53-c6e69ed3337e:sendIQ" type="result" from="conference.chat.example.org" to="arzu.horsten@chat.example.org/converse.js-138545405">
+            <command xmlns="http://jabber.org/protocol/commands" node="http://prosody.im/protocol/hats#add" sessionid="141a571b-37e2-4891-824f-72ca4b64806f" status="executing">
+                <x xmlns="jabber:x:data" type="form">
+                    <title>Add a hat</title>
+                    <instructions>Assign a hat to a room member</instructions>
+                    <field label="User JID" type="jid-single" var="user"><required/></field>
+                    <field label="Room JID" type="jid-single" var="room"><required/></field>
+                    <field label="Hat title" type="text-single" var="title"/>
+                    <field label="Hat URI" type="text-single" var="uri"><required/></field>
+                </x>
+                <actions execute="complete"><next/><complete/></actions>
+            </command>
+        </iq>
+        */
+
+
     }
 });

+ 24 - 0
src/modals/user-settings.js

@@ -0,0 +1,24 @@
+import { BootstrapModal } from "../converse-modal.js";
+import tpl_client_info_modal from "templates/client_info_modal";
+
+let _converse;
+
+export default BootstrapModal.extend({
+    id: "converse-client-info-modal",
+
+    initialize (settings) {
+        _converse  = settings._converse;
+        BootstrapModal.prototype.initialize.apply(this, arguments);
+    },
+
+    toHTML () {
+        return tpl_client_info_modal(
+            Object.assign(
+                this.model.toJSON(),
+                this.model.vcard.toJSON(),
+                { 'version_name': _converse.VERSION_NAME }
+            )
+        );
+    }
+});
+

+ 33 - 8
src/templates/client_info_modal.js

@@ -2,10 +2,13 @@ import { __ } from '@converse/headless/i18n';
 import { html } from "lit-html";
 import { modal_header_close_button } from "./buttons"
 import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
+import '../components/adhoc-commands.js';
 import xss from "xss/dist/xss";
 
 
-const modal_title = __('About');
+const i18n_modal_title = __('Settings');
+const i18n_about = __('About');
+const i18n_commands = __('Commands');
 
 const first_subtitle = __(
     '%1$s Open Source %2$s XMPP chat client brought to you by %3$s Opkode %2$s',
@@ -20,21 +23,43 @@ const second_subtitle = __(
     '</a>'
 );
 
+const tpl_navigation = (o) => html`
+    <ul class="nav nav-pills justify-content-center">
+        <li role="presentation" class="nav-item">
+            <a class="nav-link active" id="about-tab" href="#about-tabpanel" aria-controls="about-tabpanel" role="tab" data-toggle="tab" @click=${o.switchTab}>${i18n_about}</a>
+        </li>
+        <li role="presentation" class="nav-item">
+            <a class="nav-link" id="commands-tab" href="#commands-tabpanel" aria-controls="commands-tabpanel" role="tab" data-toggle="tab" @click=${o.switchTab}>${i18n_commands}</a>
+        </li>
+    </ul>
+`;
+
 
 export default (o) => html`
     <div class="modal-dialog" role="document">
         <div class="modal-content">
             <div class="modal-header">
-                <h5 class="modal-title" id="changeStatusModalLabel">${modal_title}</h5>
+                <h5 class="modal-title" id="converse-modtools-modal-label">${i18n_modal_title}</h5>
                 ${modal_header_close_button}
             </div>
             <div class="modal-body">
-                <span class="modal-alert"></span>
-                <div class="container brand-heading-container">
-                    <h6 class="brand-heading">Converse</h6>
-                    <p class="brand-subtitle">${o.version_name}</p>
-                    <p class="brand-subtitle">${unsafeHTML(xss.filterXSS(first_subtitle, {'whiteList': {'a': []}}))}</p>
-                    <p class="brand-subtitle">${unsafeHTML(xss.filterXSS(second_subtitle, {'whiteList': {'a': []}}))}</p>
+                ${ tpl_navigation(o) }
+
+                <div class="tab-content">
+                    <div class="tab-pane tab-pane--columns active" id="about-tabpanel" role="tabpanel" aria-labelledby="about-tab">
+                        <span class="modal-alert"></span>
+                        <br/>
+                        <div class="container brand-heading-container">
+                            <h6 class="brand-heading">Converse</h6>
+                            <p class="brand-subtitle">${o.version_name}</p>
+                            <p class="brand-subtitle">${unsafeHTML(xss.filterXSS(first_subtitle, {'whiteList': {'a': []}}))}</p>
+                            <p class="brand-subtitle">${unsafeHTML(xss.filterXSS(second_subtitle, {'whiteList': {'a': []}}))}</p>
+                        </div>
+                    </div>
+
+                    <div class="tab-pane tab-pane--columns" id="commands-tabpanel" role="tabpanel" aria-labelledby="commands-tab">
+                        <converse-adhoc-commands/>
+                    </div>
                 </div>
             </div>
         </div>

+ 0 - 41
src/templates/muc_commands_modal.js

@@ -1,41 +0,0 @@
-import { __ } from '@converse/headless/i18n';
-import { html } from "lit-html";
-import { modal_close_button, modal_header_close_button } from "./buttons"
-import { repeat } from 'lit-html/directives/repeat.js';
-
-
-const i18n_commands_found = __('Commands found');
-const i18n_no_commands_found = __('No commands found');
-
-
-const tpl_command = (o, command) => html`
-    <li class="room-item list-group-item">
-        <div class="available-chatroom d-flex flex-row">
-            <a class="open-room available-room w-100"
-               @click=${o.openRoom}
-               data-command-node="${command.node}"
-               data-command-jid="${command.jid}"
-               data-command-name="${command.name}"
-               title="${command.name}"
-               href="#">${command.name || command.jid}</a>
-        </div>
-    </li>
-`;
-
-export default (o) => html`
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title" id="room-details-modal-label">${o.display_name}</h5>
-                ${modal_header_close_button}
-            </div>
-            <div class="modal-body">
-                <ul class="list-group">
-                    <li class="list-group-item active">${ o.commands.length ? i18n_commands_found : i18n_no_commands_found }:</li>
-                    ${repeat(o.commands, item => item.jid, item => tpl_command(o, item))}
-                </ul>
-            </div>
-            <div class="modal-footer">${modal_close_button}</div>
-        </div>
-    </div>
-`;

+ 1 - 1
src/templates/profile.js

@@ -14,7 +14,7 @@ export default (o) => html`
                 <canvas class="avatar align-self-center" height="40" width="40"></canvas>
             </a>
             <span class="username w-100 align-self-center">${o.fullname}</span>
-            ${o._converse.api.settings.get('show_client_info') ? html`<a class="controlbox-heading__btn show-client-info fa fa-info-circle align-self-center" title="${i18n_details}"></a>` : ''}
+            ${o._converse.api.settings.get('show_client_info') ? html`<a class="controlbox-heading__btn show-client-info fa fa-cog align-self-center" title="${i18n_details}" @click=${o.showUserSettingsModal}></a>` : ''}
             ${o._converse.api.settings.get('allow_logout') ? html`<a class="controlbox-heading__btn logout fa fa-sign-out-alt align-self-center" title="${i18n_logout}"></a>` : ''}
         </div>
         <div class="d-flex xmpp-status">

+ 2 - 2
src/utils/html.js

@@ -26,7 +26,7 @@ const URL_REGEX = /\b(https?\:\/\/|www\.|https?:\/\/www\.)[^\s<>]{2,200}\b\/?/g;
 function getAutoCompleteProperty (name, options) {
     return {
         'muc#roomconfig_lang': 'language',
-        'muc#roomconfig_roomsecret': options.new_password ? 'new-password' : 'current-password'
+        'muc#roomconfig_roomsecret': options?.new_password ? 'new-password' : 'current-password'
     }[name];
 }
 
@@ -660,7 +660,7 @@ u.xForm2webForm = function (field, stanza, options) {
             'id': u.getUniqueId(),
             'label': field.getAttribute('label') || '',
             'name': name,
-            'fixed_username': options.fixed_username,
+            'fixed_username': options?.fixed_username,
             'autocomplete': getAutoCompleteProperty(name, options),
             'placeholder': null,
             'required': !!field.querySelector('required'),

+ 0 - 1
webpack.html

@@ -28,7 +28,6 @@
         enable_smacks: true,
         i18n: 'en',
         message_archiving: 'always',
-        persistent_store: 'IndexedDB',
         muc_domain: 'conference.chat.example.org',
         muc_respect_autojoin: true,
         view_mode: 'fullscreen',