Browse Source

Add Broadcast-Messages (Announcements/MOTD)

Christoph Scholz 5 years ago
parent
commit
6f3f53dc3a

+ 1 - 0
CHANGES.md

@@ -10,6 +10,7 @@
 - #1823: New config options [muc_roomid_policy](https://conversejs.org/docs/html/configuration.html#muc-roomid-policy)
     and [muc_roomid_policy_hint](https://conversejs.org/docs/html/configuration.html#muc-roomid-policy-hint)
 - #1826: A user can now add himself as a contact
+- #1834: Add Broadcast-Message (Announcements/MOTD) functionality
 
 ## 6.0.0 (2020-01-09)
 

+ 21 - 0
sass/_chatbox.scss

@@ -555,6 +555,27 @@
 
 }
 
+#conversejs {
+    #service-administration {
+        .flyout {
+            border-color: var(--service-administration-color);
+        }
+        .chat-head-chatbox {
+            background-color: var(--service-administration-color);
+        }
+        .chat-body {
+            background-color: var(--chat-textarea-background-color);
+            display: block;
+            padding: 1em;
+            overflow: auto;
+        }
+        .send-button {
+            margin-top: 1em;
+            width: 100%;
+        }
+    }
+}
+
 #conversejs.converse-embedded {
     .converse-chatboxes {
         z-index: 1031; // One more than bootstrap navbar

+ 15 - 0
sass/_controlbox.scss

@@ -358,6 +358,21 @@
     }
 }
 
+#conversejs {
+    #controlbox {
+        #service-admin-panel {
+            .controlbox-heading--service-administration {
+                color: var(--service-administration-color);
+            }
+            .service-admin-selector {
+                color: var(--service-administration-color);
+            }
+            .service-admin-selector-caret {
+                color: var(--service-administration-color);
+            }
+        }
+    }
+}
 
 #conversejs.converse-overlayed {
     .toggle-controlbox {

+ 4 - 0
sass/_variables.scss

@@ -8,6 +8,7 @@ $mobile_portrait_length: 480px !default;
     --redder-orange: #E77051;
     --orange: #E7A151;
     --light-blue: #578EA9;
+    --darker-blue: #3c8be0;
 
     --chat-status-online: var(--green);
     --chat-status-busy: var(--redder-orange);
@@ -126,6 +127,9 @@ $mobile_portrait_length: 480px !default;
     --headline-head-color: var(--orange);
     --headline-message-color: #D2842B;
 
+    --service-administration-color: var(--darker-blue);
+    --service-administration-chat-background-color: white;
+
     --chatbox-button-size: 14px;
     --fullpage-chatbox-button-size: 16px;
 

+ 244 - 0
spec/announce.js

@@ -0,0 +1,244 @@
+(function (root, factory) {
+    define([
+        "jasmine",
+        "mock",
+        "test-utils"
+        ], factory);
+} (this, function (jasmine, mock, test_utils) {
+    "use strict";
+    const Strophe = converse.env.Strophe,
+          $iq = converse.env.$iq,
+          _ = converse.env._,
+          u = converse.env.utils;
+
+    describe("Broadcast-/MOTD-Messages (XEP-0133)", function () {
+
+        describe("Discovering support", function () {
+            it("is done automatically", mock.initConverse(async (done, _converse) => {
+                const IQ_stanzas = _converse.connection.IQ_stanzas;
+                const IQ_ids =  _converse.connection.IQ_ids;
+                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.COMMANDS], []);
+
+                await u.waitUntil(() => _.filter(
+                    IQ_stanzas,
+                    iq => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]')).length
+                );
+
+                /* <iq from='montague.tld'
+                 *      id='step_01'
+                 *      to='romeo@montague.tld/garden'
+                 *      type='result'>
+                 *  <query xmlns='http://jabber.org/protocol/disco#items'>
+                 *      <item jid='montague.tld' name='Set message of the day and send to online users' node='http://jabber.org/protocol/admin#set-motd' />
+                 *      <item jid='montague.tld' name="Update message of the day (don't send)" node='http://jabber.org/protocol/admin#edit-motd' />
+                 *  </query>
+                 *  </iq>
+                 */
+                let stanza = _.find(IQ_stanzas, function (iq) {
+                    return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]');
+                });
+                const items_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+                stanza = $iq({
+                    'type': 'result',
+                    'from': 'montague.lit',
+                    'to': 'romeo@montague.lit/orchard',
+                    'id': items_IQ_id
+                }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
+                    .c('item', {
+                        'jid': 'montague.lit',
+                        'name': 'Set message of the day and send to online users',
+                        'node': 'http://jabber.org/protocol/admin#set-motd'})
+                    .c('item', {
+                        'jid': 'montague.lit',
+                        'name': "Update message of the day (don't send)",
+                        'node': 'http://jabber.org/protocol/admin#edit-motd'});
+
+                _converse.connection._dataRecv(test_utils.createRequest(stanza));
+                expect(_converse.serviceAdminCommands.length).toBe(2);
+                done();
+            }))
+        });
+
+        describe("When not supported", function () {
+            it("the service admin menu is not shown", mock.initConverse(async (done, _converse) => {
+                const IQ_stanzas = _converse.connection.IQ_stanzas;
+                const IQ_ids =  _converse.connection.IQ_ids;
+                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.COMMANDS], []);
+
+                await u.waitUntil(() => _.filter(
+                    IQ_stanzas,
+                    iq => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]')).length
+                );
+
+                /* <iq from='montague.tld'
+                 *      id='step_01'
+                 *      to='romeo@montague.tld/garden'
+                 *      type='error'>
+                 *  <query node='announce' xmlns='http://jabber.org/protocol/disco#items' />
+                 *   <error code='404' type='cancel'>
+                 *      <item-not-found xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+                 *  </error>
+                 *  </iq>
+                 */
+                let stanza = _.find(IQ_stanzas, function (iq) {
+                    return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]');
+                });
+                const items_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+                stanza = $iq({
+                    'type': 'error',
+                    'from': 'montague.lit',
+                    'to': 'romeo@montague.lit/orchard',
+                    'id': items_IQ_id
+                }).c('query', {'node':'announce', 'xmlns': 'http://jabber.org/protocol/disco#items'}).up()
+                .c('error', {'code': '404', 'type': 'cancel'})
+                    .c('item-not-found', {
+                        'xmlns': 'urn:ietf:params:xml:ns:xmpp-stanzas'});
+
+                _converse.connection._dataRecv(test_utils.createRequest(stanza));
+                const view = _converse.chatboxviews.get('controlbox');
+                expect(view.el.querySelector('.service-admin-menu')).toBe(null);
+                done();
+            }))
+        });
+
+        describe("When supported", function () {
+            it("the service admin menu is shown", mock.initConverse(async (done, _converse) => {
+                const IQ_stanzas = _converse.connection.IQ_stanzas;
+                const IQ_ids =  _converse.connection.IQ_ids;
+                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.COMMANDS], []);
+
+                await u.waitUntil(() => _.filter(
+                    IQ_stanzas,
+                    iq => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]')).length
+                );
+
+                /* <iq from='montague.tld'
+                 *      id='step_01'
+                 *      to='romeo@montague.tld/garden'
+                 *      type='result'>
+                 *  <query xmlns='http://jabber.org/protocol/disco#items'>
+                 *      <item jid='montague.tld' name='Set message of the day and send to online users' node='http://jabber.org/protocol/admin#set-motd' />
+                 *      <item jid='montague.tld' name="Update message of the day (don't send)" node='http://jabber.org/protocol/admin#edit-motd' />
+                 *  </query>
+                 *  </iq>
+                 */
+                let stanza = _.find(IQ_stanzas, function (iq) {
+                    return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]');
+                });
+                const items_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+                stanza = $iq({
+                    'type': 'result',
+                    'from': 'montague.lit',
+                    'to': 'romeo@montague.lit/orchard',
+                    'id': items_IQ_id
+                }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
+                    .c('item', {
+                        'jid': 'montague.lit',
+                        'name': 'Set message of the day and send to online users',
+                        'node': 'http://jabber.org/protocol/admin#set-motd'})
+                    .c('item', {
+                        'jid': 'montague.lit',
+                        'name': "Update message of the day (don't send)",
+                        'node': 'http://jabber.org/protocol/admin#edit-motd'});
+
+                _converse.connection._dataRecv(test_utils.createRequest(stanza));
+                const view = _converse.chatboxviews.get('controlbox');
+                expect(view.el.querySelector('.service-admin-menu')).not.toBe(null);
+                done();
+            }))
+            it("a user may send an announce command", mock.initConverse(async (done, _converse) => {
+                const IQ_stanzas = _converse.connection.IQ_stanzas;
+                const IQ_ids =  _converse.connection.IQ_ids;
+                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.COMMANDS], []);
+
+                await u.waitUntil(() => _.filter(
+                    IQ_stanzas,
+                    iq => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]')).length
+                );
+
+                /* <iq from='montague.tld'
+                 *      id='step_01'
+                 *      to='romeo@montague.tld/garden'
+                 *      type='result'>
+                 *  <query xmlns='http://jabber.org/protocol/disco#items'>
+                 *      <item jid='montague.tld' name='Set message of the day and send to online users' node='http://jabber.org/protocol/admin#set-motd' />
+                 *      <item jid='montague.tld' name="Update message of the day (don't send)" node='http://jabber.org/protocol/admin#edit-motd' />
+                 *  </query>
+                 *  </iq>
+                 */
+                let stanza = _.find(IQ_stanzas, function (iq) {
+                    return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]');
+                });
+                const items_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+                stanza = $iq({
+                    'type': 'result',
+                    'from': 'montague.lit',
+                    'to': 'romeo@montague.lit/orchard',
+                    'id': items_IQ_id
+                }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
+                    .c('item', {
+                        'jid': 'montague.lit',
+                        'name': 'Set message of the day and send to online users',
+                        'node': 'http://jabber.org/protocol/admin#set-motd'})
+                    .c('item', {
+                        'jid': 'montague.lit',
+                        'name': "Update message of the day (don't send)",
+                        'node': 'http://jabber.org/protocol/admin#edit-motd'});
+
+                _converse.connection._dataRecv(test_utils.createRequest(stanza));
+                let view = _converse.chatboxviews.get('controlbox');
+                expect(view.el.querySelector('.service-admin-menu')).not.toBe(null);
+
+                await test_utils.openControlBox(_converse);
+                view.el.querySelector("a[title='edit-motd']").click();
+                await u.waitUntil(() => _.filter(
+                    IQ_stanzas,
+                    iq => iq.querySelector('iq[to="montague.lit"] command[node="http://jabber.org/protocol/admin#edit-motd"]')).length
+                );
+                stanza = _.find(IQ_stanzas, function (iq) {
+                    return iq.querySelector('iq[to="montague.lit"] command[node="http://jabber.org/protocol/admin#edit-motd"]');
+                });
+                expect(stanza).not.toBe(null);
+
+                const command_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+                stanza = u.toStanza(`
+                    <iq from="montague.lit" id="${command_IQ_id}" to="romeo@montague.lit/orchard" type="result" xmlns="jabber:client">
+                      <command node="http://jabber.org/protocol/admin#edit-motd" status="executing" xmlns="http://jabber.org/protocol/commands">
+                          <actions execute="complete">
+                              <complete/>
+                          </actions>
+                          <x type="form" xmlns="jabber:x:data">
+                              <title>Update message of the day (don't send)</title>
+                              <field type="hidden" var="FORM_TYPE">
+                                  <value>http://jabber.org/protocol/admin</value>
+                              </field>
+                              <field label="Subject" type="text-single" var="subject"/>
+                             <field label="Message body" type="text-multi" var="body"/>
+                          </x>
+                      </command>
+                    </iq>`);
+                _converse.connection._dataRecv(test_utils.createRequest(stanza));
+
+                view = _converse.chatboxviews.get(_converse.SERVICE_ADMIN_TYPE);
+                const inputs = view.el.querySelectorAll('.form-control');
+                inputs[0].value = 'Intelligent subject';
+                inputs[1].value = 'Extraordinary content';
+                spyOn(_converse.connection, 'send');
+                view.el.querySelector('.send-button').click();
+                expect(_converse.connection.send).toHaveBeenCalled();
+                stanza = _converse.connection.send.calls.argsFor(0)[0];
+                expect(Strophe.serialize(stanza)).toBe(
+                    `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="montague.lit" type="set" xml:lang="en" xmlns="jabber:client">`+
+                    `<command node="http://jabber.org/protocol/admin#edit-motd" xmlns="http://jabber.org/protocol/commands">`+
+                    `<x type="submit" xmlns="jabber:x:data">`+
+                    `<field type="hidden" var="FORM_TYPE"><value>http://jabber.org/protocol/admin</value></field>`+
+                    `<field var="subject"><value>Intelligent subject</value></field>`+
+                    `<field var="body"><value>Extraordinary content</value></field>`+
+                    `</x></command></iq>`
+                );
+                done();
+            }))
+
+        });
+    });
+}));

+ 412 - 0
src/converse-service-administration.js

@@ -0,0 +1,412 @@
+// Converse.js (A browser based XMPP chat client)
+// https://conversejs.org
+//
+// Copyright (c) 2012-2017, Jan-Carel Brand <jc@opkode.com>
+// Licensed under the Mozilla Public License (MPLv2)
+//
+
+import "converse-chatview";
+import "converse-profile";
+import "converse-rosterview";
+import converse from "@converse/headless/converse-core";
+import sizzle from "sizzle";
+import tpl_form_checkbox from "templates/form_checkbox.html";
+import tpl_form_input from "templates/form_input.html";
+import tpl_form_textarea from "templates/form_textarea.html";
+import tpl_service_admin_chat from "templates/service_administration_chat.html";
+import tpl_service_admin_option from "templates/service_administration_option.html";
+import tpl_service_admin_selector from "templates/service_administration_selector.html";
+
+const { $iq, Backbone, Strophe } = converse.env;
+const u = converse.env.utils;
+
+converse.plugins.add('converse-service-administration', {
+
+    /* Plugin dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin.
+     *
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found. By default it's
+     * false, which means these plugins are only loaded opportunistically.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     */
+    dependencies: ["converse-chatboxes", "converse-rosterview", "converse-chatview"],
+
+    initialize () {
+        const { _converse } = this,
+            { __ } = _converse;
+
+        _converse.SERVICE_ADMIN_TYPE = 'service-administration';
+
+        _converse.ServiceAdministration = Backbone.Model.extend({
+            async discoverSupport () {
+                const supported = await _converse.api.disco.supports(Strophe.NS.COMMANDS, _converse.bare_jid);
+                if (supported) {
+                    const stanza = $iq({
+                        'from': _converse.connection.jid,
+                        'id': u.getUniqueId(),
+                        'to': _converse.domain,
+                        'type': 'get'
+                    }).c('query', {
+                        xmlns: Strophe.NS.DISCO_ITEMS,
+                        node: 'announce'
+                    }).up();
+
+                    _converse.connection.sendIQ(stanza, result => {
+                        const commands = sizzle('item[node^="http://jabber.org/protocol/admin"]', result);
+                        _converse.serviceAdminCommands = new _converse.ServiceAdminCommandCollection();
+                        commands.forEach(command => {
+                            const command_description = command.getAttribute('name');
+                            const command_node = command.getAttribute('node');
+                            const command_name = command_node.split('#').pop();
+
+                            _converse.serviceAdminCommands.add(new _converse.ServiceAdminCommandItem({
+                                command_node: command_node,
+                                description: command_description,
+                                command_name: command_name
+                            }));
+                        });
+                        this.renderControlboxElement();
+                    });
+                }
+            },
+
+            renderControlboxElement () {
+                _converse.serviceAdministrationCommandView = new _converse.ServiceAdminCommandView({collection: _converse.serviceAdminCommands});
+                const groupchat_element = _converse.chatboxviews.get('controlbox').el.querySelector('#chatrooms');
+                if (groupchat_element !== null) {
+                    groupchat_element.insertAdjacentElement('beforebegin', _converse.serviceAdministrationCommandView.render());
+                }
+            }
+        });
+
+        _converse.ServiceAdminCommandItem = Backbone.Model.extend({});
+
+        _converse.ServiceAdminCommandItemView = Backbone.NativeView.extend({
+            tagName: 'div',
+            className: 'list-item controlbox-padded d-flex flex-row',
+            events: {
+                'click': 'openServiceAdminChat'
+            },
+
+            render () {
+                this.el.innerHTML = tpl_service_admin_option({
+                    'name': this.model.get('command_name'),
+                    'service_admin_name': this.model.get('command_name')
+                });
+                return this.el;
+            },
+
+            openServiceAdminChat () {
+                const stanza = $iq({
+                    'from': _converse.connection.jid,
+                    'id': u.getUniqueId(),
+                    'to': _converse.domain,
+                    'type': 'set',
+                    'xml:lang': _converse.locale
+                }).c('command', {
+                    xmlns: Strophe.NS.COMMANDS,
+                    action: 'execute',
+                    node: this.model.attributes.command_node
+                }).up();
+
+                _converse.connection.sendIQ(stanza, result => {
+                    const session_id = sizzle('command', result).pop().getAttribute('sessionid');
+                    const fields = sizzle('field', result);
+                    const form_type_value = sizzle('field[var="FORM_TYPE"] > value', result).pop().innerHTML;
+
+                    const attributes = {
+                        'id': _converse.SERVICE_ADMIN_TYPE,
+                        'jid': _converse.domain,
+                        'type': _converse.SERVICE_ADMIN_TYPE,
+                        'from': _converse.domain,
+                        'fields': Array.from(fields,
+                                             field => this.getAttributesFromField(field)),
+                        'form_type_value': form_type_value,
+                        'name': this.model.get('name'),
+                        'node': this.model.get('command_node'),
+                        'header_text': this.model.attributes.description,
+                        'send_button_text': __('send'),
+                        'placeholder_text': this.model.attributes.description,
+                        'session_id': session_id
+                    };
+
+                    const existing_chatbox = _converse.chatboxes.get(_converse.SERVICE_ADMIN_TYPE);
+                    if (existing_chatbox) {
+                        existing_chatbox.set(attributes);
+                        existing_chatbox.maybeShow(true);
+                    } else {
+                        const chatbox = new _converse.ServiceAdminBox(attributes);
+
+                        _converse.chatboxes.add(chatbox);
+                        chatbox.maybeShow(true);
+                    }
+                }, error => {
+                    _converse.log(error, Strophe.LogLevel.ERROR);
+                });
+            },
+
+            getAttributesFromField (field) {
+                const attribute_names = field.getAttributeNames();
+                const obj = {};
+                attribute_names.forEach(name => {
+                    obj[name] = field.getAttribute(name);
+                });
+                obj["value"] = this.getValueOfFieldElement(field);
+                obj["required"] = field.getElementsByTagName('required').length !== 0;
+                if (obj.var === "body" && obj.value !== null && obj.value.length === 0) {
+                    obj.value = __("-- sent from %1$s",
+                            Strophe.getBareJidFromJid(_converse.jid));
+                }
+                return obj;
+            },
+
+            getValueOfFieldElement (field) {
+                const values = field.getElementsByTagName('value');
+                if (values.length > 0) {
+                    return values[0].innerHTML;
+                } else {
+                    return '';
+                }
+            },
+        });
+
+        _converse.ServiceAdminCommandCollection = Backbone.Collection.extend ({
+            model: _converse.ServiceAdminCommandItem,
+        });
+
+        _converse.ServiceAdminCommandView = Backbone.NativeView.extend({
+            tagName: 'div',
+            className: 'controlbox-section',
+            id: 'service-admin-panel',
+            collection: _converse.ServiceAdminCommandCollection,
+            events: {
+                'click a.service-admin-selector': 'toggleAdminOptions'
+            },
+
+            render () {
+                this.el.innerHTML = tpl_service_admin_selector({
+                    'title': __('Select the Service-admin Function'),
+                    'label': __('Announcement')
+                });
+
+                const service_admin_menu = this.el.querySelector('.service-admin-menu');
+                this.collection.forEach(command => {
+                    const itemView = new _converse.ServiceAdminCommandItemView({ model: command });
+                    service_admin_menu.insertAdjacentElement('beforeend', itemView.render());
+                });
+                return this.el;
+            },
+
+            toggleAdminOptions (ev) {
+                ev.preventDefault();
+                u.slideToggleElement(this.el.querySelector(".service-admin-menu"));
+
+                // toggle caret on List-Item
+                const admin_list_caret = this.el.querySelector('.service-admin-selector-caret');
+                if (admin_list_caret.classList.contains('fa-caret-right')) {
+                    admin_list_caret.classList.remove('fa-caret-right');
+                    admin_list_caret.classList.add('fa-caret-down');
+                } else {
+                    admin_list_caret.classList.remove('fa-caret-down');
+                    admin_list_caret.classList.add('fa-caret-right');
+                }
+            }
+        });
+
+        _converse.ServiceAdminBox = _converse.ChatBox.extend({
+            defaults () {
+                return {
+                    'bookmarked': false,
+                    'hidden': ['mobile', 'fullscreen'].includes(_converse.view_mode),
+                    'message_type': 'service-administration',
+                    'num_unread': 0,
+                    'time_opened': this.get('time_opened') || (new Date()).getTime(),
+                    'type': _converse.SERVICE_ADMIN_TYPE,
+                }
+            },
+
+            initialize () {
+                this.set({'box_id': `box-${btoa(this.get('jid'))}`});
+            }
+        });
+
+        _converse.ServiceAdminBoxView = _converse.ChatBoxView.extend({
+            className: 'chatbox',
+            id: 'service-administration',
+            events: {
+                'click .send-button': 'sendMessageToServer',
+                'click .chatbox-btn': 'close'
+            },
+
+            initialize () {
+                _converse.api.trigger('chatBoxInitialized', this);
+                this.listenTo(this.model, 'destroy', this.hide);
+                this.listenTo(this.model, 'hide', this.hide);
+                this.listenTo(this.model, 'show', this.show);
+                this.listenTo(this.model, 'change:closed', this.ensureClosedState);
+
+                this.render();
+            },
+
+            render () {
+                this.el.innerHTML = tpl_service_admin_chat({
+                    'service_admin_header': this.model.attributes.header_text,
+                    'info_close': __('Close'),
+                    'send_button_text': this.model.attributes.send_button_text,
+                    'placeholder_text': this.model.attributes.placeholder_text,
+                });
+
+                this.createGuiElementsByFieldType();
+                this.insertSendButton();
+
+                const converse_html = _converse.chatboxviews.el.querySelector('#controlbox');
+                converse_html.insertAdjacentElement('afterend', this.el);
+
+                return this.el;
+            },
+
+            createGuiElementsByFieldType () {
+                this.model.attributes.fields.forEach(field => {
+                    const type = field['type'];
+                    switch (type) {
+                        case 'hidden': break;
+                        case 'text-single': this.renderTextSingleField(field); break;
+                        case 'text-multi': this.renderTextMultiField(field); break;
+                        case 'boolean': this.renderBooleanField(field); break;
+                        default: break;
+                    }
+                });
+            },
+
+            renderTextSingleField (field) {
+                const element = tpl_form_input({
+                    'type': field['type'],
+                    'label': field['label'],
+                    'id': field['label'],
+                    'name': field['var'],
+                    'value': field['value'],
+                    'autocomplete': false,
+                    'placeholder': ''
+                });
+                const chat_body = this.el.querySelector('.chat-body');
+                chat_body.insertAdjacentHTML('beforeend', element);
+            },
+
+            renderTextMultiField (field) {
+                const element = tpl_form_textarea({
+                    'label': field['label'],
+                    'name': field['var'],
+                    'value': field['value']
+                });
+
+                const chat_body = this.el.querySelector('.chat-body');
+                chat_body.insertAdjacentHTML('beforeend', element);
+
+                const text_area = sizzle('textarea[name="' + field['var'] + '"]', chat_body).pop();
+                text_area.classList.add('form-control');
+            },
+
+            renderBooleanField (field) {
+                const element = tpl_form_checkbox({
+                    'id': field['var'],
+                    'name': field['var'],
+                    'label': field['label'],
+                    'checked': field['value'],
+                    'required': field["required"]
+                });
+
+                const chat_body = this.el.querySelector('.chat-body');
+                chat_body.insertAdjacentHTML('beforeend', element);
+            },
+
+            insertSendButton () {
+                const button = '<input type="button" class="send-button" value="send"/>';
+                const chat_body = this.el.querySelector('.chat-body');
+                chat_body.insertAdjacentHTML('beforeend', button);
+            },
+
+            show () {
+                this.render();
+                _converse.api.trigger('beforeShowingChatView', this);
+                this.el.classList.remove('hidden');
+            },
+
+            hide () {
+                this.el.classList.add('hidden');
+                _converse.api.trigger('chatBoxClosed', this);
+                return this;
+            },
+
+            close (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                this.model.close();
+                this.remove();
+
+                _converse.api.trigger('chatBoxClosed', this);
+                return this;
+            },
+
+            sendMessageToServer (ev) {
+                ev.preventDefault();
+
+                const stanza = $iq({
+                    'from': _converse.connection.jid,
+                    'to': _converse.domain,
+                    'id': u.getUniqueId(),
+                    'type': 'set',
+                    'xml:lang': _converse.locale
+                }).c('command', {
+                    xmlns: Strophe.NS.COMMANDS,
+                    node: this.model.attributes.node,
+                    sessionid: this.model.attributes.session_id
+                }).c('x', {
+                    xmlns: 'jabber:x:data',
+                    type: 'submit'
+                }).c('field', {
+                    type: 'hidden',
+                    var: 'FORM_TYPE'
+                }).c('value').t(this.model.attributes.form_type_value).up().up();
+
+                this.model.attributes.fields.forEach(field => {
+                    const element = this.el.querySelector('[name="' + field['var'] + '"]');
+                    if (element && element.value !== '') {
+                        let value;
+                        if (element.type === 'checkbox') {
+                            value = element.checked;
+                        } else {
+                            value = element.value;
+                        }
+
+                        stanza.c('field', {
+                            var: field['var']
+                        }).c('value').t(value).up().up();
+                    }
+                });
+
+                _converse.connection.sendIQ(stanza, () => {
+                    alert(__("The command was executed successfully."));
+                }, error => {
+                    const error_text = sizzle('error > text', error).pop().innerHTML;
+                    alert(__("The command was NOT executed successfully for the following reason: " + error_text));
+                });
+            },
+        });
+
+        _converse.api.listen.on('rosterViewInitialized', () => {
+            _converse.serviceAdmin = new _converse.ServiceAdministration();
+            _converse.serviceAdmin.discoverSupport();
+        });
+
+        _converse.api.listen.on('chatBoxViewsInitialized', () => {
+            const views = _converse.chatboxviews;
+            _converse.chatboxes.on('add', item => {
+                if (!views.get(item.get('id')) && item.get('type') === _converse.SERVICE_ADMIN_TYPE) {
+                    views.add(item.get('id'), new _converse.ServiceAdminBoxView({model: item}));
+                }
+            });
+        });
+    }
+});

+ 2 - 0
src/converse.js

@@ -28,6 +28,7 @@ import "converse-push";            // XEP-0357 Push Notifications
 import "converse-register";        // XEP-0077 In-band registration
 import "converse-roomslist";       // Show currently open chat rooms
 import "converse-rosterview";
+import "converse-service-administration"; // adds Service-administration functionality (XEP-0133)
 import "converse-singleton";
 import "converse-uniview";
 /* END: Removable components */
@@ -58,6 +59,7 @@ const WHITELISTED_PLUGINS = [
     'converse-register',
     'converse-roomslist',
     'converse-rosterview',
+    'converse-service-administration',
     'converse-singleton',
     'converse-uniview'
 ];

+ 1 - 0
src/headless/converse-core.js

@@ -37,6 +37,7 @@ dayjs.extend(advancedFormat);
 Strophe.addNamespace('CARBONS', 'urn:xmpp:carbons:2');
 Strophe.addNamespace('CHATSTATES', 'http://jabber.org/protocol/chatstates');
 Strophe.addNamespace('CSI', 'urn:xmpp:csi:0');
+Strophe.addNamespace('COMMANDS', 'http://jabber.org/protocol/commands');
 Strophe.addNamespace('DELAY', 'urn:xmpp:delay');
 Strophe.addNamespace('FASTEN', 'urn:xmpp:fasten:0');
 Strophe.addNamespace('FORWARD', 'urn:xmpp:forward:0');

+ 20 - 0
src/templates/service_administration_chat.html

@@ -0,0 +1,20 @@
+<div class="flyout box-flyout">
+    <div>
+        <div class="chat-head chat-head-chatbox row no-gutters">
+            <div class="chatbox-navback"><i class="fa fa-arrow-left"></i></div>
+            <div class="chatbox-title">
+                <div class="row no-gutter">
+                    <div class="col chat-title" title="Service Administration (XEP-0133)">Service Administration (XEP-0133)
+                        <p class="user-custom-message"> {{{o.service_admin_header}}}</p>
+                    </div>
+                </div>
+            </div>
+            <div class="chatbox-buttons row no-gutters">
+                <a class="chatbox-btn close-chatbox-button fa fa-times" title="{{{o.info_close}}}"></a>
+            </div>
+        </div>
+    </div>
+    <div class="chat-body">
+        <!-- form-elements will be inserted here -->
+    </div>
+</div>

+ 1 - 0
src/templates/service_administration_option.html

@@ -0,0 +1 @@
+<a class="list-item-link w-100" title="{{{o.name}}}" href="#">{{{o.service_admin_name}}}</a>

+ 12 - 0
src/templates/service_administration_selector.html

@@ -0,0 +1,12 @@
+<div class="d-flex controlbox-padded">
+    <span class="w-100 controlbox-heading controlbox-heading--service-administration">{{{o.label}}}</span>
+</div>
+<div class="list-container" style="margin-top: 8px; margin-bottom: 2px;">
+    <a href="#" class="list-toggle controlbox-padded service-admin-selector" title="{{{o.title}}}">
+        <span class="service-admin-selector service-admin-selector-caret fa fa-caret-right"></span>
+         Broadcast-Messages
+    </a>
+    <div class="items-list service-admin-menu collapsed">
+        <!-- the diffrent Broadcast-Message-Options will be inserted here (service_admin_option.html)-->
+    </div>
+</div>

+ 1 - 0
tests/runner.js

@@ -39,6 +39,7 @@ var specs = [
     "spec/converse",
     "spec/bookmarks",
     "spec/headline",
+    "spec/announce",
     "spec/disco",
     "spec/protocol",
     "spec/presence",