Ver Fonte

Refactor "Add MUC" modal

- Escape JID before creating a new MUC
- Allow adding/entering a MUC by specifying only a name
    If no MUC service is provided, we fetch the default service and use that.
- Add validation for the MUC name/jid
- Add test for the muc_roomid_policy setting
- Create normalized MUC JID when only a name is provided
JC Brand há 5 meses atrás
pai
commit
1f6670baef

+ 1 - 0
CHANGES.md

@@ -64,6 +64,7 @@
 - New "getOccupantActionButtons" hook, so that plugins can add actions on MUC occupants.
 - MUC occupants badges: displays short labels, with full label as title.
 - New config option [stanza_timeout](https://conversejs.org/docs/html/configuration.html#show-background)
+- Update the "Add MUC" modal to add validation and to allow specifying only the MUC name and not the whole address.
 
 ### Default config changes
 - Make `fullscreen` the default `view_mode`.

+ 12 - 4
src/headless/plugins/disco/api.js

@@ -8,6 +8,7 @@ import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
 import log from '../../log.js';
+import DiscoEntity from './entity.js';
 
 const { Strophe, $iq } = converse.env;
 
@@ -174,7 +175,7 @@ export default {
          * @returns {promise} Promise which resolves once we have a result from the server.
          */
         items(jid, node) {
-            const attrs = { 'xmlns': Strophe.NS.DISCO_ITEMS };
+            const attrs = { xmlns: Strophe.NS.DISCO_ITEMS };
             if (node) {
                 attrs.node = node;
             }
@@ -200,6 +201,7 @@ export default {
              * @method api.disco.entities.get
              * @param {string} jid The Jabber ID of the entity
              * @param {boolean} [create] Whether the entity should be created if it doesn't exist.
+             * @return {Promise<DiscoEntity|DiscoEntities|undefined>}
              * @example _converse.api.disco.entities.get(jid);
              */
             async get(jid, create = false) {
@@ -227,7 +229,10 @@ export default {
              * @param {string} jid - The Jabber ID of the entity for which we want to fetch items
              * @example api.disco.entities.items(jid);
              */
-            items(jid) {
+            async items(jid) {
+                const entity = await api.disco.entities.get(jid);
+                await entity.waitUntilItemsFetched;
+
                 const disco_entities = /** @type {DiscoEntities} */ (_converse.state.disco_entities);
                 return disco_entities.filter((e) => e.get('parent_jids')?.includes(jid));
             },
@@ -293,9 +298,11 @@ export default {
                     return [];
                 }
 
+                const items = await api.disco.entities.items(jid);
+
                 const promises = [
                     entity.getFeature(feature),
-                    ...api.disco.entities.items(jid).map((i) => i.getFeature(feature)),
+                    ...items.map((i) => i.getFeature(feature)),
                 ];
                 const result = await Promise.all(promises);
                 return result.filter((f) => f instanceof Object);
@@ -331,7 +338,8 @@ export default {
                     return true;
                 }
 
-                const result = await Promise.all(api.disco.entities.items(jid).map((i) => i.getFeature(feature)));
+                const items = await api.disco.entities.items(jid);
+                const result = await Promise.all(items.map((i) => i.getFeature(feature)));
                 return result.map((f) => f instanceof Object).includes(true);
             },
         },

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

@@ -26,6 +26,7 @@ class DiscoEntity extends Model {
     initialize (_, options) {
         super.initialize();
         this.waitUntilFeaturesDiscovered = getOpenPromise();
+        this.waitUntilItemsFetched = getOpenPromise();
 
         this.dataforms = new Collection();
         let id = `converse.dataforms-${this.get('jid')}`;
@@ -144,7 +145,8 @@ class DiscoEntity extends Model {
             const jid = item.getAttribute('jid');
             const entity = _converse.state.disco_entities.get(jid);
             if (entity) {
-                entity.set({ parent_jids: [this.get('jid')] });
+                const parent_jids = entity.get('parent_jids');
+                entity.set({ parent_jids: [...parent_jids, this.get('jid')] });
             } else {
                 api.disco.entities.create({
                     jid,
@@ -191,6 +193,8 @@ class DiscoEntity extends Model {
         if (stanza.querySelector(`feature[var="${Strophe.NS.DISCO_ITEMS}"]`)) {
             await this.queryForItems();
         }
+        this.waitUntilItemsFetched.resolve();
+
         Array.from(stanza.querySelectorAll('feature')).forEach(feature => {
             this.features.create({
                 'var': feature.getAttribute('var'),

+ 3 - 3
src/headless/plugins/disco/tests/disco.js

@@ -114,9 +114,9 @@ describe("Service Discovery", function () {
             ]);
             const { api, domain } = _converse;
             let entity = entities.get(_converse.domain);
-            expect(api.disco.entities.items(domain).length).toBe(3);
-
-            expect(api.disco.entities.items(domain).map(e => e.get('jid'))).toEqual(
+            const domain_items = await api.disco.entities.items(domain);
+            expect(domain_items.length).toBe(3);
+            expect(domain_items.map(e => e.get('jid'))).toEqual(
                 ['people.shakespeare.lit', 'plays.shakespeare.lit', 'words.shakespeare.lit']
             )
 

+ 2 - 2
src/headless/plugins/muc/index.js

@@ -6,8 +6,8 @@ import MUCOccupant from './occupant.js';
 import MUCOccupants from './occupants.js';
 import './plugin.js';
 
-import { isChatRoom } from './utils.js';
+import { getDefaultMUCService, isChatRoom } from './utils.js';
 import { setAffiliation } from './affiliations/utils.js';
-Object.assign(u, { muc: { isChatRoom, setAffiliation }});
+Object.assign(u, { muc: { isChatRoom, setAffiliation, getDefaultMUCService }});
 
 export { MUCMessage, MUCMessages, MUC, MUCOccupant, MUCOccupants };

+ 18 - 0
src/headless/plugins/muc/utils.js

@@ -9,6 +9,24 @@ import { getUnloadEvent } from '../../utils/session.js';
 
 const { Strophe, sizzle, u } = converse.env;
 
+/**
+ * @returns {Promise<string|undefined>}
+ */
+export async function getDefaultMUCService () {
+    let muc_service = api.settings.get('muc_domain');
+    if (!muc_service) {
+        const domain = _converse.session.get('domain');
+        const items = await api.disco.entities.items(domain);
+        for (const item of items) {
+            if (await api.disco.features.has(Strophe.NS.MUC, item.get('jid'))) {
+                muc_service = item.get('jid');
+                break;
+            }
+        }
+    }
+    return muc_service;
+}
+
 /**
  * @param {import('@converse/skeletor').Model} model
  */

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

@@ -84,9 +84,10 @@ declare namespace _default {
              * @method api.disco.entities.get
              * @param {string} jid The Jabber ID of the entity
              * @param {boolean} [create] Whether the entity should be created if it doesn't exist.
+             * @return {Promise<DiscoEntity|DiscoEntities|undefined>}
              * @example _converse.api.disco.entities.get(jid);
              */
-            function get(jid: string, create?: boolean): Promise<any>;
+            function get(jid: string, create?: boolean): Promise<DiscoEntity | DiscoEntities | undefined>;
             /**
              * Return any disco items advertised on this entity
              *
@@ -94,7 +95,7 @@ declare namespace _default {
              * @param {string} jid - The Jabber ID of the entity for which we want to fetch items
              * @example api.disco.entities.items(jid);
              */
-            function items(jid: string): any;
+            function items(jid: string): Promise<any>;
             /**
              * Create a new  disco entity. It's identity and features
              * will automatically be fetched from cache or from the
@@ -254,4 +255,5 @@ export default _default;
 export type DiscoState = import("./index").DiscoState;
 export type DiscoEntities = import("./entities").default;
 export type Collection = import("@converse/skeletor").Collection;
+import DiscoEntity from './entity.js';
 //# sourceMappingURL=api.d.ts.map

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

@@ -11,6 +11,7 @@ export default DiscoEntity;
 declare class DiscoEntity extends Model {
     initialize(_: any, options: any): void;
     waitUntilFeaturesDiscovered: any;
+    waitUntilItemsFetched: any;
     dataforms: Collection;
     features: Collection;
     fields: Collection;

+ 4 - 0
src/headless/types/plugins/muc/utils.d.ts

@@ -1,3 +1,7 @@
+/**
+ * @returns {Promise<string|undefined>}
+ */
+export function getDefaultMUCService(): Promise<string | undefined>;
 /**
  * @param {import('@converse/skeletor').Model} model
  */

+ 9 - 3
src/headless/utils/jid.js

@@ -5,10 +5,16 @@ import { Strophe } from 'strophe.js';
  * @returns {boolean}
  */
 export function isValidJID(jid) {
-    if (typeof jid === 'string') {
-        return jid.split('@').filter((s) => !!s).length === 2 && !jid.startsWith('@') && !jid.endsWith('@');
+    if (!(typeof jid === 'string')) {
+        return false;
     }
-    return false;
+
+    const num_slashes = jid.split('/').length - 1;
+    if (num_slashes > 1) {
+        return false;
+    }
+
+    return jid.split('@').filter((s) => !!s).length === 2 && !jid.startsWith('@') && !jid.endsWith('@');
 }
 
 /**

+ 6 - 9
src/plugins/chatview/tests/http-file-upload.js

@@ -53,14 +53,9 @@ describe("XEP-0363: HTTP File Upload", function () {
             expect(entities.get(_converse.domain).features.length).toBe(2);
             expect(entities.get(_converse.domain).identities.length).toBe(1);
 
-            api.disco.entities.get().then(entities => {
-                expect(entities.length).toBe(3);
-                expect(entities.pluck('jid')).toEqual(['montague.lit', 'romeo@montague.lit', 'upload.montague.lit']);
-                expect(api.disco.entities.items('montague.lit').length).toBe(1);
-                // Converse.js sees that the entity has a disco#info feature, so it will make a query for it.
-                const selector = 'iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]';
-                return u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).length > 0);
-            });
+            const domain_items = await api.disco.entities.items('montague.lit')
+            expect(domain_items.length).toBe(1);
+            // Converse.js sees that the entity has a disco#info feature, so it will make a query for it.
 
             selector = 'iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]';
             stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop(), 1000);
@@ -311,7 +306,9 @@ describe("XEP-0363: HTTP File Upload", function () {
                     expect(entities.get(_converse.domain).features.length).toBe(2);
                     expect(entities.get(_converse.domain).identities.length).toBe(1);
                     expect(entities.pluck('jid')).toEqual(['montague.lit', 'romeo@montague.lit', 'upload.montague.lit']);
-                    expect(api.disco.entities.items('montague.lit').length).toBe(1);
+
+                    const items = await api.disco.entities.items('montague.lit');
+                    expect(items.length).toBe(1);
                     await u.waitUntil(function () {
                         // Converse.js sees that the entity has a disco#info feature,
                         // so it will make a query for it.

+ 87 - 36
src/plugins/muc-views/modals/add-muc.js

@@ -1,7 +1,8 @@
+import { _converse, api, converse } from '@converse/headless';
+import AutoCompleteComponent from 'shared/autocomplete/component.js';
 import tplAddMuc from './templates/add-muc.js';
 import BaseModal from 'plugins/modal/modal.js';
 import { __ } from 'i18n';
-import { _converse, api, converse } from '@converse/headless';
 
 import '../styles/add-muc-modal.scss';
 
@@ -9,10 +10,8 @@ const u = converse.env.utils;
 const { Strophe } = converse.env;
 
 export default class AddMUCModal extends BaseModal {
-    initialize () {
+    initialize() {
         super.initialize();
-        this.listenTo(this.model, 'change:muc_domain', () => this.render());
-        this.muc_roomid_policy_error_msg = null;
         this.render();
         this.addEventListener(
             'shown.bs.modal',
@@ -23,15 +22,19 @@ export default class AddMUCModal extends BaseModal {
         );
     }
 
-    renderModal () {
+    renderModal() {
         return tplAddMuc(this);
     }
 
-    getModalTitle () {
+    getModalTitle() {
         return __('Enter a new Groupchat');
     }
 
-    parseRoomDataFromEvent (form) {
+    /**
+     * @param {HTMLFormElement} form
+     * @returns {{ jid: string, nick: string }}
+     */
+    parseRoomDataFromEvent(form) {
         const data = new FormData(form);
         const jid = /** @type {string} */ (data.get('chatroom'))?.trim();
         let nick;
@@ -43,50 +46,98 @@ export default class AddMUCModal extends BaseModal {
         } else {
             nick = /** @type {string} */ (data.get('nickname')).trim();
         }
-        return {
-            'jid': jid,
-            'nick': nick,
-        };
+        return { jid, nick };
     }
 
-    openChatRoom (ev) {
+    /**
+     * Takes a string and returns a normalized lowercase value representing the node (localpart) of a MUC JID.
+     * Replaces all spaces with dashes, replaces diacritics with ASCII, and
+     * removes all characters besides letters and numbers and dashes.
+     * @param {string} s
+     * @returns {string}
+     */
+    normalizeNode(s) {
+        return s
+            .trim()
+            .replace(/\s+/g, '-')
+            .replace(/\u0142/g, "l")
+            .replace(/[^\x00-\x7F]/g, (c) => c.normalize('NFD').replace(/[\u0300-\u036f]/g, ''))
+            .replace(/[^a-zA-Z0-9-]/g, '-')
+            .replace(/-+/g, '-')
+            .replace(/[^a-zA-Z0-9]$/g, '')
+            .toLowerCase();
+    }
+
+    /**
+     * @param {Event} ev
+     */
+    async openChatRoom(ev) {
         ev.preventDefault();
-        if (this.checkRoomidPolicy()) return;
 
-        const data = this.parseRoomDataFromEvent(ev.target);
-        if (data.nick === '') {
-            // Make sure defaults apply if no nick is provided.
-            data.nick = undefined;
-        }
+        const autocomplete_el = /** @type {AutoCompleteComponent} */ (this.querySelector('converse-autocomplete'));
+        if (autocomplete_el.onChange().error_message) return;
+
+        const { escapeNode, getNodeFromJid, getDomainFromJid } = Strophe;
+        const form = /** @type {HTMLFormElement} */ (ev.target);
+
+        const data = this.parseRoomDataFromEvent(form);
+        const settings = {
+            nick: data.nick ?? undefined,
+        };
+
         let jid;
-        if (api.settings.get('locked_muc_domain') || (api.settings.get('muc_domain') && !u.isValidJID(data.jid))) {
-            jid = `${Strophe.escapeNode(data.jid)}@${api.settings.get('muc_domain')}`;
-        } else {
-            jid = data.jid;
-            this.model.setDomain(jid);
+        if (api.settings.get('locked_muc_domain') || !u.isValidJID(data.jid)) {
+            const muc_service = await u.muc.getDefaultMUCService();
+            if (muc_service) {
+                settings.name = data.jid;
+                jid = `${this.normalizeNode(data.jid)}@${muc_service}`.toLowerCase();
+            }
+        }
+
+        if (!jid) {
+            jid = `${escapeNode(getNodeFromJid(data.jid))}@${getDomainFromJid(data.jid)}`.toLowerCase();
         }
 
-        api.rooms.open(jid, Object.assign(data, { jid }), true);
-        ev.target.reset();
+        api.rooms.open(jid, { ...settings, jid }, true);
+        form.reset();
         this.modal.hide();
     }
 
-    checkRoomidPolicy () {
-        if (api.settings.get('muc_roomid_policy') && api.settings.get('muc_domain')) {
-            let jid = /** @type {HTMLInputElement} */ (this.querySelector('converse-autocomplete input')).value;
+    /**
+     * @param {string} jid
+     * @return {string}
+     */
+    validateMUCJID(jid) {
+        if (jid.length === 0) {
+            return __('Invalid groupchat address, it cannot be empty.');
+        }
+
+        const num_slashes = jid.split('/').length - 1;
+        if (num_slashes > 0) {
+            return __('Invalid groupchat address, a forward slash is not allowed.');
+        }
+
+        const num_ats = jid.split('@').length - 1;
+        if (num_ats > 1) {
+            return __('Invalid groupchat address, more than one @ sign is not allowed.');
+        }
+
+        if (jid.startsWith('@') || jid.endsWith('@')) {
+            return __('Invalid groupchat address, it cannot start or end with an @ sign.');
+        }
+
+        const policy = api.settings.get('muc_roomid_policy');
+        if (policy && api.settings.get('muc_domain')) {
             if (api.settings.get('locked_muc_domain') || !u.isValidJID(jid)) {
                 jid = `${Strophe.escapeNode(jid)}@${api.settings.get('muc_domain')}`;
             }
-            const roomid = Strophe.getNodeFromJid(jid);
-            const roomdomain = Strophe.getDomainFromJid(jid);
-            if (api.settings.get('muc_domain') !== roomdomain || api.settings.get('muc_roomid_policy').test(roomid)) {
-                this.muc_roomid_policy_error_msg = null;
-            } else {
-                this.muc_roomid_policy_error_msg = __('Groupchat id is invalid.');
-                return true;
+            const muc_jid = Strophe.getNodeFromJid(jid);
+            const muc_domain = Strophe.getDomainFromJid(jid);
+            if (api.settings.get('muc_domain') === muc_domain && !policy.test(muc_jid)) {
+                return __('Groupchat id is invalid.');
             }
-            this.render();
         }
+        return '';
     }
 }
 

+ 33 - 34
src/plugins/muc-views/modals/templates/add-muc.js

@@ -9,12 +9,12 @@ const nickname_input = () => {
     const i18n_nickname = __('Nickname');
     const i18n_required_field = __('This field is required');
     return html`
-        <div>
+        <div class="mb-3">
             <label for="nickname" class="form-label">${i18n_nickname}:</label>
             <input
                 type="text"
                 title="${i18n_required_field}"
-                required="required"
+                required
                 name="nickname"
                 value="${_converse.exports.getDefaultMUCNickname() || ''}"
                 class="form-control"
@@ -28,50 +28,49 @@ const nickname_input = () => {
  */
 export default (el) => {
     const i18n_join = __('Join');
-    const muc_domain = el.model.get('muc_domain') || api.settings.get('muc_domain');
+    const muc_domain = api.settings.get('muc_domain');
 
     let placeholder = '';
-    if (!api.settings.get('locked_muc_domain')) {
+    let label_name;
+    if (api.settings.get('locked_muc_domain')) {
+        label_name = __('Groupchat name');
+    } else {
         placeholder = muc_domain ? `name@${muc_domain}` : __('name@conference.example.org');
+        label_name = __('Groupchat name or address');
     }
 
-    const label_room_address = muc_domain ? __('Groupchat name') : __('Groupchat address');
-    const muc_roomid_policy_error_msg = el.muc_roomid_policy_error_msg;
-    const muc_roomid_policy_hint = api.settings.get('muc_roomid_policy_hint');
+    const policy_hint = api.settings.get('muc_roomid_policy_hint');
     const muc_search_service = api.settings.get('muc_search_service');
-    return html`
-        <form class="converse-form add-chatroom" @submit=${(ev) => el.openChatRoom(ev)}>
-            <div>
-                <label for="chatroom" class="form-label">${label_room_address}:</label>
-                ${muc_roomid_policy_error_msg
-                    ? html`<label class="form-label roomid-policy-error">${muc_roomid_policy_error_msg}</label>`
-                    : ''}
+
+    return html` <form
+        class="converse-form add-chatroom needs-validation"
+        @submit=${(ev) => el.openChatRoom(ev)}
+        novalidate
+    >
+        <div class="mb-3">
+            <label for="chatroom" class="form-label">${label_name}:</label>
+            <div class="input-group">
                 ${muc_search_service
                     ? html` <converse-autocomplete
-                          .getAutoCompleteList=${getAutoCompleteList}
-                          ?autofocus=${true}
-                          min_chars="3"
-                          position="below"
-                          placeholder="${placeholder}"
+                          .getAutoCompleteList="${getAutoCompleteList}"
+                          .validate="${/** @param {string} v */ (v) => el.validateMUCJID(v)}"
+                          ?autofocus="${true}"
                           class="add-muc-autocomplete"
+                          min_chars="3"
                           name="chatroom"
-                      >
-                      </converse-autocomplete>`
+                          placeholder="${placeholder}"
+                          position="below"
+                          required
+                      ></converse-autocomplete>`
                     : ''}
             </div>
-            ${muc_roomid_policy_hint
-                ? html`<div>
-                      ${unsafeHTML(DOMPurify.sanitize(muc_roomid_policy_hint, { 'ALLOWED_TAGS': ['b', 'br', 'em'] }))}
+            ${policy_hint
+                ? html`<div class="mb-3">
+                      ${unsafeHTML(DOMPurify.sanitize(policy_hint, { 'ALLOWED_TAGS': ['b', 'br', 'em'] }))}
                   </div>`
                 : ''}
-            ${!api.settings.get('locked_muc_nickname') ? nickname_input() : ''}
-            <input
-                type="submit"
-                class="btn btn-primary"
-                name="join"
-                value="${i18n_join || ''}"
-                ?disabled="${muc_roomid_policy_error_msg}"
-            />
-        </form>
-    `;
+        </div>
+        ${!api.settings.get('locked_muc_nickname') ? nickname_input() : ''}
+        <input type="submit" class="btn btn-primary mt-3" name="join" value="${i18n_join || ''}" />
+    </form>`;
 };

+ 1 - 0
src/plugins/muc-views/styles/add-muc-modal.scss

@@ -1,6 +1,7 @@
 converse-add-muc-modal {
     .add-chatroom {
         converse-autocomplete {
+            width: 100%;
             .suggestion-box__results--below {
                 height: 10em;
                 overflow: auto;

+ 119 - 63
src/plugins/muc-views/tests/muc-add-modal.js

@@ -1,22 +1,15 @@
 /*global mock, converse */
-
 const {  Promise, sizzle, u } = converse.env;
 
+
 describe('The "Groupchats" Add modal', function () {
 
     it('can be opened from a link in the "Groupchats" section of the controlbox',
         mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
-            await mock.openControlBox(_converse);
-            await mock.waitForRoster(_converse, 'current', 0);
-
-            const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
-            roomspanel.querySelector('.show-add-muc-modal').click();
-            mock.closeControlBox(_converse);
-            const modal = _converse.api.modal.get('converse-add-muc-modal');
-            await u.waitUntil(() => u.isVisible(modal), 1000);
+            const modal = await mock.openAddMUCModal(_converse);
 
             let label_name = modal.querySelector('label[for="chatroom"]');
-            expect(label_name.textContent.trim()).toBe('Groupchat address:');
+            expect(label_name.textContent.trim()).toBe('Groupchat name or address:');
             const name_input = modal.querySelector('input[name="chatroom"]');
             expect(name_input.placeholder).toBe('name@conference.example.org');
 
@@ -32,26 +25,17 @@ describe('The "Groupchats" Add modal', function () {
             modal.querySelector('form input[type="submit"]').click();
             await u.waitUntil(() => _converse.chatboxes.length);
             await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
-
-            roomspanel.model.set('muc_domain', 'muc.example.org');
-            roomspanel.querySelector('.show-add-muc-modal').click();
-            label_name = modal.querySelector('label[for="chatroom"]');
-            expect(label_name.textContent.trim()).toBe('Groupchat name:');
-            await u.waitUntil(() => modal.querySelector('input[name="chatroom"]')?.placeholder === 'name@muc.example.org');
         })
     );
 
     it("doesn't require the domain when muc_domain is set",
         mock.initConverse(['chatBoxesFetched'], { 'muc_domain': 'muc.example.org' }, async function (_converse) {
-            await mock.openControlBox(_converse);
-            const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
-            roomspanel.querySelector('.show-add-muc-modal').click();
-            const modal = _converse.api.modal.get('converse-add-muc-modal');
-            await u.waitUntil(() => u.isVisible(modal), 1000);
+            const modal = await mock.openAddMUCModal(_converse);
+
             expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
             spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
             const label_name = modal.querySelector('label[for="chatroom"]');
-            expect(label_name.textContent.trim()).toBe('Groupchat name:');
+            expect(label_name.textContent.trim()).toBe('Groupchat name or address:');
             let name_input = modal.querySelector('input[name="chatroom"]');
             expect(name_input.placeholder).toBe('name@muc.example.org');
             name_input.value = 'lounge';
@@ -64,6 +48,7 @@ describe('The "Groupchats" Add modal', function () {
             expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@muc.example.org')).toBe(true);
 
             // However, you can still open MUCs with different domains
+            const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
             roomspanel.querySelector('.show-add-muc-modal').click();
             await u.waitUntil(() => u.isVisible(modal), 1000);
             name_input = modal.querySelector('input[name="chatroom"]');
@@ -79,46 +64,117 @@ describe('The "Groupchats" Add modal', function () {
         })
     );
 
-    it('only uses the muc_domain is locked_muc_domain is true',
-        mock.initConverse(
-            ['chatBoxesFetched'],
-            { 'muc_domain': 'muc.example.org', 'locked_muc_domain': true },
-            async function (_converse) {
-                await mock.openControlBox(_converse);
-                const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
-                roomspanel.querySelector('.show-add-muc-modal').click();
-                const modal = _converse.api.modal.get('converse-add-muc-modal');
-                await u.waitUntil(() => u.isVisible(modal), 1000);
-                expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
-                spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
-                const label_name = modal.querySelector('label[for="chatroom"]');
-                expect(label_name.textContent.trim()).toBe('Groupchat name:');
-                let name_input = modal.querySelector('input[name="chatroom"]');
-                expect(name_input.placeholder).toBe('');
-                name_input.value = 'lounge';
-                let nick_input = modal.querySelector('input[name="nickname"]');
-                nick_input.value = 'max';
-                modal.querySelector('form input[type="submit"]').click();
-                await u.waitUntil(() => _converse.chatboxes.length);
-                await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
-                expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@muc.example.org')).toBe(true);
-
-                // However, you can still open MUCs with different domains
-                roomspanel.querySelector('.show-add-muc-modal').click();
-                await u.waitUntil(() => u.isVisible(modal), 1000);
-                name_input = modal.querySelector('input[name="chatroom"]');
-                name_input.value = 'lounge@conference';
-                nick_input = modal.querySelector('input[name="nickname"]');
-                nick_input.value = 'max';
-                modal.querySelector('form input[type="submit"]').click();
-                await u.waitUntil(
-                    () => _converse.chatboxes.models.filter(c => c.get('type') === 'chatroom').length === 2
-                );
-                await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 2);
-                expect(
-                    _converse.chatboxes.models.map(m => m.get('id')).includes('lounge\\40conference@muc.example.org')
-                ).toBe(true);
-            }
-        )
+    it('only uses the muc_domain if locked_muc_domain is true', mock.initConverse(
+        ['chatBoxesFetched'], { muc_domain: 'muc.example.org', locked_muc_domain: true },
+        async function (_converse) {
+            const modal = await mock.openAddMUCModal(_converse);
+
+            expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
+            spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+            const label_name = modal.querySelector('label[for="chatroom"]');
+            expect(label_name.textContent.trim()).toBe('Groupchat name:');
+            let name_input = modal.querySelector('input[name="chatroom"]');
+            expect(name_input.placeholder).toBe('');
+            name_input.value = 'lounge';
+            let nick_input = modal.querySelector('input[name="nickname"]');
+            nick_input.value = 'max';
+            modal.querySelector('form input[type="submit"]').click();
+            await u.waitUntil(() => _converse.chatboxes.length);
+            await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
+            expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@muc.example.org')).toBe(true);
+
+            // However, you can still open MUCs with different domains
+            const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
+            roomspanel.querySelector('.show-add-muc-modal').click();
+            await u.waitUntil(() => u.isVisible(modal), 1000);
+            name_input = modal.querySelector('input[name="chatroom"]');
+            name_input.value = 'lounge@conference';
+            nick_input = modal.querySelector('input[name="nickname"]');
+            nick_input.value = 'max';
+            modal.querySelector('form input[type="submit"]').click();
+            await u.waitUntil(
+                () => _converse.chatboxes.models.filter(c => c.get('type') === 'chatroom').length === 2
+            );
+            await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 2);
+            expect(
+                _converse.chatboxes.models.map(m => m.get('id')).includes('lounge-conference@muc.example.org')
+            ).toBe(true);
+        })
+    );
+
+    fit("lets you create a MUC with only the name",
+        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+            const { domain } = _converse;
+            await mock.waitUntilDiscoConfirmed(
+                _converse,
+                domain,
+                [{ category: 'server', type: 'IM' }],
+                [Strophe.NS.DISCO_ITEMS],
+            );
+
+            const modal = await mock.openAddMUCModal(_converse);
+            spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+
+            const name_input = modal.querySelector('input[name="chatroom"]');
+            name_input.value = 'The Lounge';
+
+            const nick_input = modal.querySelector('input[name="nickname"]');
+            nick_input.value = 'max';
+
+            modal.querySelector('form input[type="submit"]').click();
+
+            await mock.waitUntilDiscoConfirmed(_converse, domain, [], [], ['muc.example.org'], 'items');
+            await mock.waitUntilDiscoConfirmed(_converse, 'muc.example.org', [], [Strophe.NS.MUC]);
+
+            await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
+            expect(_converse.chatboxes.models.map(m => m.get('id')).includes('the-lounge@muc.example.org')).toBe(true);
+
+            const muc = _converse.chatboxes.get('the-lounge@muc.example.org');
+            expect(muc.get('name')).toBe('The Lounge');
+        })
+    );
+
+    it("normalizes the MUC name when creating the corresponding JID",
+        mock.initConverse(['chatBoxesFetched'], {muc_domain: 'montague.lit'}, async function (_converse) {
+            const modal = await mock.openAddMUCModal(_converse);
+            spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+
+            const name_input = modal.querySelector('input[name="chatroom"]');
+            name_input.value = 'Into the Äther: A Journey';
+
+            const nick_input = modal.querySelector('input[name="nickname"]');
+            nick_input.value = 'max';
+
+            modal.querySelector('form input[type="submit"]').click();
+
+            await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
+            expect(_converse.chatboxes.models.map(m => m.get('id')).includes('into-the-ather-a-journey@montague.lit')).toBe(true);
+
+            expect(modal.normalizeNode('Into the Äther: A Journey')).toBe('into-the-ather-a-journey');
+            expect(modal.normalizeNode(' A silly summer song ∷ ')).toBe('a-silly-summer-song');
+        })
+    );
+
+    it("applies a muc_roomid_policy",
+        mock.initConverse(['chatBoxesFetched'], {
+            muc_domain: 'montague.lit',
+            muc_roomid_policy: /^[a-z0-9._-]{5,40}$/,
+            muc_roomid_policy_hint: '<br><b>Policy for groupchat id:</b><br>- between 5 and 40 characters,<br>- lowercase from a to z (no special characters) or<br>- digits or<br>- dots (.) or<br>- underlines (_) or<br>- hyphens (-),<br>- no spaces<br>',
+        }, async function (_converse) {
+            const modal = await mock.openAddMUCModal(_converse);
+
+            spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+            const name_input = modal.querySelector('input[name="chatroom"]');
+            name_input.value = 'lounge\ music@montague.lit';
+
+            const nick_input = modal.querySelector('input[name="nickname"]');
+            nick_input.value = 'max';
+
+            modal.querySelector('form input[type="submit"]').click();
+
+            await u.waitUntil(() => name_input.classList.contains('error'));
+            expect(name_input.classList.contains('is-invalid')).toBe(true);
+            expect(modal.querySelector('.invalid-feedback')?.textContent).toBe('Groupchat id is invalid.');
+        })
     );
 });

+ 1 - 1
src/plugins/muc-views/tests/muc.js

@@ -2287,7 +2287,7 @@ describe("Groupchats", function () {
         it("will show an error message if the groupchat requires a password",
                 mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
 
-            const muc_jid = 'protected';
+            const muc_jid = 'protected@montague.lit';
             await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
             const view = _converse.chatboxviews.get(muc_jid);
 

+ 3 - 3
src/plugins/roomslist/model.js

@@ -8,9 +8,9 @@ class RoomsListModel extends Model {
 
     defaults () {
         return {
-            'muc_domain': api.settings.get('muc_domain'),
-            'toggle_state':  OPENED,
-            'collapsed_domains': [],
+            muc_domain: api.settings.get('muc_domain'),
+            toggle_state:  OPENED,
+            collapsed_domains: [],
         };
     }
 

+ 11 - 11
src/plugins/roomslist/templates/roomslist.js

@@ -136,11 +136,12 @@ export default (el) => {
     const is_closed = el.model.get('toggle_state') === CLOSED;
 
     const btns = [
-        html`<a class="dropdown-item show-bookmark-list-modal" role="button"
-                @click="${(ev) => api.modal.show('converse-bookmark-list-modal', { 'model': el.model }, ev)}"
-                data-toggle="modal">
-                    <converse-icon class="fa fa-bookmark" size="1em"></converse-icon>
-                    ${i18n_show_bookmarks}
+        html`<a class="dropdown-item show-add-muc-modal" role="button"
+                @click="${(ev) => api.modal.show('converse-add-muc-modal', { 'model': el.model }, ev)}"
+                data-toggle="modal"
+                data-target="#add-chatrooms-modal">
+                    <converse-icon class="fa fa-plus" size="1em"></converse-icon>
+                    ${i18n_title_new_room}
         </a>`,
         html`<a class="dropdown-item show-list-muc-modal" role="button"
                 @click="${(ev) => api.modal.show('converse-muc-list-modal', { 'model': el.model }, ev)}"
@@ -149,12 +150,11 @@ export default (el) => {
                     <converse-icon class="fa fa-list-ul" size="1em"></converse-icon>
                     ${i18n_title_list_rooms}
         </a>`,
-        html`<a class="dropdown-item show-add-muc-modal" role="button"
-                @click="${(ev) => api.modal.show('converse-add-muc-modal', { 'model': el.model }, ev)}"
-                data-toggle="modal"
-                data-target="#add-chatrooms-modal">
-                    <converse-icon class="fa fa-plus" size="1em"></converse-icon>
-                    ${i18n_title_new_room}
+        html`<a class="dropdown-item show-bookmark-list-modal" role="button"
+                @click="${(ev) => api.modal.show('converse-bookmark-list-modal', { 'model': el.model }, ev)}"
+                data-toggle="modal">
+                    <converse-icon class="fa fa-bookmark" size="1em"></converse-icon>
+                    ${i18n_show_bookmarks}
         </a>`,
     ];
 

+ 58 - 38
src/shared/autocomplete/component.js

@@ -3,37 +3,41 @@ import { CustomElement } from 'shared/components/element.js';
 import { FILTER_CONTAINS, FILTER_STARTSWITH } from './utils.js';
 import { api } from '@converse/headless';
 import { html } from 'lit';
+import {ancestor} from 'utils/html.js';
 
 /**
  * A custom element that can be used to add auto-completion suggestions to a form input.
  * @class AutoCompleteComponent
  *
- * @property { "above" | "below" } [position="above"]
+ * @property {"above" | "below"} [position="above"]
  *  Should the autocomplete list show above or below the input element?
- * @property { Boolean } [autofocus=false]
+ * @property {Boolean} [autofocus=false]
  *  Should the `focus` attribute be set on the input element?
- * @property { Function } getAutoCompleteList
+ * @property {Function} getAutoCompleteList
  *  A function that returns the list of autocomplete suggestions
- * @property { Function } data
+ * @property {Function} data
  *  A function that maps the returned matches into the correct format
- * @property { Array } list
+ * @property {Array} list
  *  An array of suggestions, to be used instead of the `getAutoCompleteList` *  function
- * @property { Boolean } [auto_evaluate=true]
+ * @property {Boolean} [auto_evaluate=true]
  *  Should evaluation happen automatically without any particular key as trigger?
- * @property { Boolean } [auto_first=false]
+ * @property {Boolean} [auto_first=false]
  *  Should the first element automatically be selected?
  * @property { "contains" | "startswith" } [filter="contains"]
  *  Provide matches which contain the entered text, or which starts with the entered text
- * @property { String } [include_triggers=""]
+ * @property {String} [include_triggers=""]
  *  Space separated characters which should be included in the returned value
- * @property { Number } [min_chars=1]
+ * @property {Number} [min_chars=1]
  *  The minimum number of characters to be entered into the input before autocomplete starts.
- * @property { String } [name]
+ * @property {String} [name]
  *  The `name` attribute of the `input` element
- * @property { String } [placeholder]
+ * @property {String} [placeholder]
  *  The `placeholder` attribute of the `input` element
- * @property { String } [triggers]
+ * @property {String} [triggers]
  *  String of space separated characters which trigger autocomplete
+ * @property {Function} [validate]
+ *  A validation function that returns a string containing a validation error
+ *  message in case the validation failed.
  *
  * @example
  *     <converse-autocomplete
@@ -45,42 +49,46 @@ import { html } from 'lit';
 export default class AutoCompleteComponent extends CustomElement {
     static get properties () {
         return {
-            'position': { type: String },
-            'autofocus': { type: Boolean },
-            'getAutoCompleteList': { type: Function },
-            'data': { type: Function },
-            'list': { type: Array },
-            'auto_evaluate': { type: Boolean },
-            'auto_first': { type: Boolean },
-            'filter': { type: String },
-            'include_triggers': { type: String },
-            'min_chars': { type: Number },
-            'name': { type: String },
-            'placeholder': { type: String },
-            'value': { type: String },
-            'triggers': { type: String },
-            'required': { type: Boolean },
+            auto_evaluate: { type: Boolean },
+            auto_first: { type: Boolean },
+            autofocus: { type: Boolean },
+            data: { type: Function },
+            error_message: { type: String },
+            filter: { type: String },
+            getAutoCompleteList: { type: Function },
+            include_triggers: { type: String },
+            list: { type: Array },
+            min_chars: { type: Number },
+            name: { type: String },
+            placeholder: { type: String },
+            position: { type: String },
+            required: { type: Boolean },
+            triggers: { type: String },
+            validate: { type: Function },
+            value: { type: String },
         };
     }
 
     constructor () {
         super();
-        this.data = (a) => a;
-        this.value = '';
-        this.position = 'above';
         this.auto_evaluate = true;
         this.auto_first = false;
+        this.data = (a) => a;
+        this.error_message = '';
         this.filter = 'contains';
+        this.getAutoCompleteList = null;
         this.include_triggers = '';
+        this.list = null;
         this.match_current_word = false; // Match only the current word, otherwise all input is matched
         this.max_items = 10;
         this.min_chars = 1;
-        this.triggers = '';
-        this.getAutoCompleteList = null;
-        this.list = null;
         this.name = '';
         this.placeholder = '';
+        this.position = 'above';
         this.required = false;
+        this.triggers = '';
+        this.validate = null;
+        this.value = '';
     }
 
     render () {
@@ -89,16 +97,18 @@ export default class AutoCompleteComponent extends CustomElement {
             <div class="suggestion-box suggestion-box__name">
                 <ul class="suggestion-box__results ${position_class}" hidden=""></ul>
                 <input
+                    .validate=${this.validate}
                     ?autofocus=${this.autofocus}
                     ?required=${this.required}
-                    type="text"
-                    name="${this.name}"
-                    autocomplete="off"
+                    @change=${this.onChange}
                     @keydown=${this.onKeyDown}
                     @keyup=${this.onKeyUp}
-                    class="form-control suggestion-box__input"
-                    value="${this.value}"
+                    autocomplete="off"
+                    class="form-control suggestion-box__input ${this.error_message ? 'is-invalid error' : ''}"
+                    name="${this.name}"
                     placeholder="${this.placeholder}"
+                    type="text"
+                    value="${this.value}"
                 />
                 <span
                     class="suggestion-box__additions visually-hidden"
@@ -107,6 +117,7 @@ export default class AutoCompleteComponent extends CustomElement {
                     aria-relevant="additions"
                 ></span>
             </div>
+            ${this.error_message ? html`<div class="invalid-feedback">${this.error_message}</div>` : ''}
         `;
     }
 
@@ -126,13 +137,22 @@ export default class AutoCompleteComponent extends CustomElement {
         this.auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false));
     }
 
+    /** @param {KeyboardEvent} ev */
     onKeyDown (ev) {
         this.auto_complete.onKeyDown(ev);
     }
 
+    /** @param {KeyboardEvent} ev */
     onKeyUp (ev) {
         this.auto_complete.evaluate(ev);
     }
+
+    onChange() {
+        const input = this.querySelector('input');
+        this.error_message = this.validate?.(input.value);
+        if (this.error_message) this.requestUpdate();
+        return this;
+    }
 }
 
 api.elements.define('converse-autocomplete', AutoCompleteComponent);

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

@@ -287,5 +287,4 @@ export function shortnamesToEmojis (str, options={unicode_only: false, add_title
     return addEmojisMarkup(str, options);
 }
 
-
 Object.assign(u, { shortnamesToEmojis });

+ 9 - 1
src/shared/styles/forms.scss

@@ -5,6 +5,11 @@
     }
 
     form {
+
+        .invalid-feedback {
+            display: block;
+        }
+
         .form-instructions {
             color: var(--text-color);
             margin-bottom: 1em;
@@ -95,10 +100,13 @@
             input[type=text] {
                 min-width: 50%;
             }
+
+            input.is_invalid,
             input.error {
-                border: 1px solid var(--error-color);
                 color: var(--text-color);
+                border-color: var(--error-color);
             }
+
             .text-muted {
                 color: var(--secondary-color) !important;
                 font-size: 85%;

+ 10 - 0
src/shared/tests/mock.js

@@ -387,6 +387,15 @@ async function receiveOwnMUCPresence (_converse, muc_jid, nick, affiliation='own
     _converse.api.connection.get()._dataRecv(createRequest(presence));
 }
 
+async function openAddMUCModal (_converse) {
+    await mock.openControlBox(_converse);
+    const controlbox = _converse.chatboxviews.get('controlbox');
+    controlbox.querySelector('converse-rooms-list .show-add-muc-modal').click();
+    const modal = _converse.api.modal.get('converse-add-muc-modal');
+    await u.waitUntil(() => u.isVisible(modal), 1000);
+    return modal;
+}
+
 async function openAndEnterChatRoom (
         _converse,
         muc_jid,
@@ -877,6 +886,7 @@ Object.assign(mock, {
     initConverse,
     initializedOMEMO,
     num_contacts,
+    openAddMUCModal,
     openAndEnterChatRoom,
     openChatBoxFor,
     openChatBoxes,

+ 23 - 5
src/types/plugins/muc-views/modals/add-muc.d.ts

@@ -1,13 +1,31 @@
 export default class AddMUCModal extends BaseModal {
-    muc_roomid_policy_error_msg: any;
     renderModal(): import("lit").TemplateResult<1>;
     getModalTitle(): any;
-    parseRoomDataFromEvent(form: any): {
+    /**
+     * @param {HTMLFormElement} form
+     * @returns {{ jid: string, nick: string }}
+     */
+    parseRoomDataFromEvent(form: HTMLFormElement): {
         jid: string;
-        nick: any;
+        nick: string;
     };
-    openChatRoom(ev: any): void;
-    checkRoomidPolicy(): boolean;
+    /**
+     * Takes a string and returns a normalized lowercase value representing the node (localpart) of a MUC JID.
+     * Replaces all spaces with dashes, replaces diacritics with ASCII, and
+     * removes all characters besides letters and numbers and dashes.
+     * @param {string} s
+     * @returns {string}
+     */
+    normalizeNode(s: string): string;
+    /**
+     * @param {Event} ev
+     */
+    openChatRoom(ev: Event): Promise<void>;
+    /**
+     * @param {string} jid
+     * @return {string}
+     */
+    validateMUCJID(jid: string): string;
 }
 import BaseModal from 'plugins/modal/modal.js';
 //# sourceMappingURL=add-muc.d.ts.map

+ 50 - 36
src/types/shared/autocomplete/component.d.ts

@@ -2,32 +2,35 @@
  * A custom element that can be used to add auto-completion suggestions to a form input.
  * @class AutoCompleteComponent
  *
- * @property { "above" | "below" } [position="above"]
+ * @property {"above" | "below"} [position="above"]
  *  Should the autocomplete list show above or below the input element?
- * @property { Boolean } [autofocus=false]
+ * @property {Boolean} [autofocus=false]
  *  Should the `focus` attribute be set on the input element?
- * @property { Function } getAutoCompleteList
+ * @property {Function} getAutoCompleteList
  *  A function that returns the list of autocomplete suggestions
- * @property { Function } data
+ * @property {Function} data
  *  A function that maps the returned matches into the correct format
- * @property { Array } list
+ * @property {Array} list
  *  An array of suggestions, to be used instead of the `getAutoCompleteList` *  function
- * @property { Boolean } [auto_evaluate=true]
+ * @property {Boolean} [auto_evaluate=true]
  *  Should evaluation happen automatically without any particular key as trigger?
- * @property { Boolean } [auto_first=false]
+ * @property {Boolean} [auto_first=false]
  *  Should the first element automatically be selected?
  * @property { "contains" | "startswith" } [filter="contains"]
  *  Provide matches which contain the entered text, or which starts with the entered text
- * @property { String } [include_triggers=""]
+ * @property {String} [include_triggers=""]
  *  Space separated characters which should be included in the returned value
- * @property { Number } [min_chars=1]
+ * @property {Number} [min_chars=1]
  *  The minimum number of characters to be entered into the input before autocomplete starts.
- * @property { String } [name]
+ * @property {String} [name]
  *  The `name` attribute of the `input` element
- * @property { String } [placeholder]
+ * @property {String} [placeholder]
  *  The `placeholder` attribute of the `input` element
- * @property { String } [triggers]
+ * @property {String} [triggers]
  *  String of space separated characters which trigger autocomplete
+ * @property {Function} [validate]
+ *  A validation function that returns a string containing a validation error
+ *  message in case the validation failed.
  *
  * @example
  *     <converse-autocomplete
@@ -38,33 +41,33 @@
  */
 export default class AutoCompleteComponent extends CustomElement {
     static get properties(): {
-        position: {
-            type: StringConstructor;
+        auto_evaluate: {
+            type: BooleanConstructor;
         };
-        autofocus: {
+        auto_first: {
             type: BooleanConstructor;
         };
-        getAutoCompleteList: {
-            type: FunctionConstructor;
+        autofocus: {
+            type: BooleanConstructor;
         };
         data: {
             type: FunctionConstructor;
         };
-        list: {
-            type: ArrayConstructor;
-        };
-        auto_evaluate: {
-            type: BooleanConstructor;
-        };
-        auto_first: {
-            type: BooleanConstructor;
+        error_message: {
+            type: StringConstructor;
         };
         filter: {
             type: StringConstructor;
         };
+        getAutoCompleteList: {
+            type: FunctionConstructor;
+        };
         include_triggers: {
             type: StringConstructor;
         };
+        list: {
+            type: ArrayConstructor;
+        };
         min_chars: {
             type: NumberConstructor;
         };
@@ -74,38 +77,49 @@ export default class AutoCompleteComponent extends CustomElement {
         placeholder: {
             type: StringConstructor;
         };
-        value: {
+        position: {
             type: StringConstructor;
         };
+        required: {
+            type: BooleanConstructor;
+        };
         triggers: {
             type: StringConstructor;
         };
-        required: {
-            type: BooleanConstructor;
+        validate: {
+            type: FunctionConstructor;
+        };
+        value: {
+            type: StringConstructor;
         };
     };
-    data: (a: any) => any;
-    value: string;
-    position: string;
     auto_evaluate: boolean;
     auto_first: boolean;
+    data: (a: any) => any;
+    error_message: string;
     filter: string;
+    getAutoCompleteList: any;
     include_triggers: string;
+    list: any;
     match_current_word: boolean;
     max_items: number;
     min_chars: number;
-    triggers: string;
-    getAutoCompleteList: any;
-    list: any;
     name: string;
     placeholder: string;
+    position: string;
     required: boolean;
+    triggers: string;
+    validate: any;
+    value: string;
     render(): import("lit").TemplateResult<1>;
     firstUpdated(): void;
     auto_complete: AutoComplete;
     auto_completing: boolean;
-    onKeyDown(ev: any): void;
-    onKeyUp(ev: any): void;
+    /** @param {KeyboardEvent} ev */
+    onKeyDown(ev: KeyboardEvent): void;
+    /** @param {KeyboardEvent} ev */
+    onKeyUp(ev: KeyboardEvent): void;
+    onChange(): this;
 }
 import { CustomElement } from 'shared/components/element.js';
 import AutoComplete from './autocomplete.js';