Przeglądaj źródła

Generalize xform parsing

Also add support for reported fields in Ad-Hoc commands.

Fixes #3155
JC Brand 1 rok temu
rodzic
commit
ac7784533d

+ 1 - 0
CHANGES.md

@@ -6,6 +6,7 @@
 - #2716: Fix issue with chat display when opening via URL
 - #3033: Add the `muc_grouped_by_domain` option to display MUCs on the same domain in collapsible groups
 - #3155: Some ad-hoc commands not working
+- #3155: Some adhoc commands aren't working
 - #3300: Adding the maxWait option for `debouncedPruneHistory`
 - #3302: debounce MUC sidebar rendering
 - #3305: New config option [muc_search_service](https://conversejs.org/docs/html/configuration.html#muc-search-service)

+ 2 - 5
src/headless/index.js

@@ -3,11 +3,8 @@ import advancedFormat from 'dayjs/plugin/advancedFormat';
 
 dayjs.extend(advancedFormat);
 
-import * as shared_constants from './shared/constants.js';
-import api from './shared/api/index.js';
+import { _converse, api, constants as shared_constants, i18n, parsers } from './shared/index.js';
 import u from './utils/index.js';
-import _converse from './shared/_converse';
-import i18n from './shared/i18n';
 import converse from './shared/api/public.js';
 import log from './log.js';
 
@@ -46,6 +43,6 @@ export { VCard, VCards } from './plugins/vcard/index.js'; // XEP-0054 VCard-temp
 import * as muc_constants from './plugins/muc/constants.js';
 const constants = Object.assign({}, shared_constants, muc_constants);
 
-export { api, converse, _converse, i18n, log, u, constants };
+export { api, converse, _converse, i18n, log, u, constants, parsers };
 
 export default converse;

+ 1 - 1
src/headless/plugins/adhoc/api.js

@@ -57,7 +57,7 @@ export default {
                 action: 'execute',
                 node,
             });
-            return parseCommandResult(await api.sendIQ(stanza), jid);
+            return parseCommandResult(await api.sendIQ(stanza));
         },
 
         /**

+ 9 - 12
src/headless/plugins/adhoc/utils.js

@@ -1,8 +1,10 @@
 /**
  * @typedef {import('lit').TemplateResult} TemplateResult
+ * @typedef {import('../../shared/parsers').XForm} XForm
  */
 import sizzle from 'sizzle';
 import converse from '../../shared/api/public.js';
+import { parseXForm } from '../../shared/parsers.js';
 
 const { Strophe, u } = converse.env;
 
@@ -30,38 +32,33 @@ export function parseForCommands(stanza) {
  * @property {string} text
  * @property {'info'|'warn'|'error'} type
  *
- * @typedef {Object} AdHocCommandResult
+ * @typedef {Object} AdHocCommandAttrs
  * @property {string} sessionid
- * @property {string} [instructions]
- * @property {TemplateResult[]} [fields]
  * @property {string[]} [actions]
  * @property {AdHocCommandResultNote} [note]
+ *
+ * @typedef {XForm & AdHocCommandAttrs} AdHocCommandResult
  */
 
 /**
  * Given a "result" IQ stanza containing the outcome of an Ad-hoc command that
  * was executed, parse it and return the values as a JSON object.
  * @param {Element} iq
- * @param {string} [jid]
  * @returns {AdHocCommandResult}
  */
-export function parseCommandResult(iq, jid) {
+export function parseCommandResult(iq) {
     const cmd_el = sizzle(`command[xmlns="${Strophe.NS.ADHOC}"]`, iq).pop();
     const note = cmd_el.querySelector('note');
 
-    const data = {
+    return {
+        ...parseXForm(iq),
         sessionid: cmd_el.getAttribute('sessionid'),
-        instructions: sizzle('x[type="form"][xmlns="jabber:x:data"] instructions', cmd_el).pop()?.textContent,
-        fields: sizzle('x[type="form"][xmlns="jabber:x:data"] field', cmd_el).map(
-            /** @param {Element} f */ (f) => u.xForm2TemplateResult(f, cmd_el, { domain: jid })
-        ),
-        actions: Array.from(cmd_el.querySelector('actions')?.children ?? []).map((a) => a.nodeName.toLowerCase()),
         note: note
             ? {
                   text: note.textContent,
                   type: note.getAttribute('type'),
               }
             : null,
+        actions: Array.from(cmd_el.querySelector('actions')?.children ?? []).map((a) => a.nodeName.toLowerCase()),
     };
-    return data;
 }

+ 21 - 0
src/headless/shared/constants.js

@@ -135,3 +135,24 @@ export const KEYCODES = {
     META: 91,
     META_RIGHT: 93,
 };
+
+export const XFORM_TYPE_MAP = {
+    'text-private': 'password',
+    'text-single': 'text',
+    'fixed': 'label',
+    'boolean': 'checkbox',
+    'hidden': 'hidden',
+    'jid-multi': 'textarea',
+    'list-single': 'dropdown',
+    'list-multi': 'dropdown'
+};
+
+export const XFORM_VALIDATE_TYPE_MAP = {
+    'xs:anyURI': 'url',
+    'xs:byte': 'number',
+    'xs:date': 'date',
+    'xs:dateTime': 'datetime',
+    'xs:int': 'number',
+    'xs:integer': 'number',
+    'xs:time': 'time',
+};

+ 7 - 0
src/headless/shared/index.js

@@ -0,0 +1,7 @@
+import * as parsers from './parsers.js';
+import * as constants from './constants.js';
+import api from './api/index.js';
+import _converse from './_converse';
+import i18n from './i18n';
+
+export { _converse, api, constants, i18n, parsers };

+ 231 - 14
src/headless/shared/parsers.js

@@ -2,14 +2,17 @@
  * @module:headless-shared-parsers
  * @typedef {module:headless-shared-parsers.Reference} Reference
  */
+import sizzle from 'sizzle';
 import _converse from './_converse.js';
 import api from './api/index.js';
 import dayjs from 'dayjs';
 import log from '../log.js';
-import sizzle from 'sizzle';
 import { Strophe } from 'strophe.js';
 import { decodeHTMLEntities } from '../utils/html.js';
-import { rejectMessage } from './actions';
+import { getAttributes } from '../utils/stanza.js';
+import { rejectMessage } from './actions.js';
+import { XFORM_TYPE_MAP,  XFORM_VALIDATE_TYPE_MAP } from './constants.js';
+
 
 const { NS } = Strophe;
 
@@ -77,11 +80,10 @@ export function getEncryptionAttributes (stanza) {
 }
 
 /**
- * @private
- * @param { Element } stanza - The message stanza
- * @param { Element } original_stanza - The original stanza, that contains the
+ * @param {Element} stanza - The message stanza
+ * @param {Element} original_stanza - The original stanza, that contains the
  *  message stanza, if it was contained, otherwise it's the message stanza itself.
- * @returns { Object }
+ * @returns {Object}
  */
 export function getRetractionAttributes (stanza, original_stanza) {
     const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
@@ -110,6 +112,10 @@ export function getRetractionAttributes (stanza, original_stanza) {
     return {};
 }
 
+/**
+ * @param {Element} stanza
+ * @param {Element} original_stanza
+ */
 export function getCorrectionAttributes (stanza, original_stanza) {
     const el = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop();
     if (el) {
@@ -126,6 +132,9 @@ export function getCorrectionAttributes (stanza, original_stanza) {
     return {};
 }
 
+/**
+ * @param {Element} stanza
+ */
 export function getOpenGraphMetadata (stanza) {
     const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
     if (fastening) {
@@ -156,6 +165,9 @@ export function getOpenGraphMetadata (stanza) {
 }
 
 
+/**
+ * @param {Element} stanza
+ */
 export function getSpoilerAttributes (stanza) {
     const spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, stanza).pop();
     return {
@@ -164,6 +176,9 @@ export function getSpoilerAttributes (stanza) {
     };
 }
 
+/**
+ * @param {Element} stanza
+ */
 export function getOutOfBandAttributes (stanza) {
     const xform = sizzle(`x[xmlns="${Strophe.NS.OUTOFBAND}"]`, stanza).pop();
     if (xform) {
@@ -177,8 +192,7 @@ export function getOutOfBandAttributes (stanza) {
 
 /**
  * Returns the human readable error message contained in a `groupchat` message stanza of type `error`.
- * @private
- * @param { Element } stanza - The message stanza
+ * @param {Element} stanza - The message stanza
  */
 export function getErrorAttributes (stanza) {
     if (stanza.getAttribute('type') === 'error') {
@@ -197,7 +211,7 @@ export function getErrorAttributes (stanza) {
 /**
  * Given a message stanza, find and return any XEP-0372 references
  * @param {Element} stanza - The message stanza
- * @returns { Reference }
+ * @returns {Reference}
  */
 export function getReferences (stanza) {
     return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => {
@@ -237,9 +251,8 @@ export function getReceiptId (stanza) {
 
 /**
  * Determines whether the passed in stanza is a XEP-0280 Carbon
- * @private
- * @param { Element } stanza - The message stanza
- * @returns { Boolean }
+ * @param {Element} stanza - The message stanza
+ * @returns {Boolean}
  */
 export function isCarbon (stanza) {
     const xmlns = Strophe.NS.CARBONS;
@@ -251,8 +264,7 @@ export function isCarbon (stanza) {
 
 /**
  * Returns the XEP-0085 chat state contained in a message stanza
- * @private
- * @param { Element } stanza - The message stanza
+ * @param {Element} stanza - The message stanza
  */
 export function getChatState (stanza) {
     return sizzle(
@@ -266,6 +278,10 @@ export function getChatState (stanza) {
     ).pop()?.nodeName;
 }
 
+/**
+ * @param {Element} stanza
+ * @param {Object} attrs
+ */
 export function isValidReceiptRequest (stanza, attrs) {
     return (
         attrs.sender !== 'me' &&
@@ -340,3 +356,204 @@ export function isServerMessage (stanza) {
 export function isArchived (original_stanza) {
     return !!sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop();
 }
+
+/**
+ * @typedef {Object} XFormReportedField
+ * @property {string} var
+ * @property {string} label
+ *
+ * @typedef {Object} XFormResultItemField
+ * @property {string} var
+ * @property {string} value
+ *
+ * @typedef {Object} XFormOption
+ * @property {string} value
+ * @property {string} label
+ * @property {boolean} selected
+ * @property {boolean} required
+ *
+ * @typedef {Object} XFormCaptchaURI
+ * @property {string} type
+ * @property {string} data
+ *
+ * @typedef {'list-single'|'list-multi'} XFormListTypes
+ * @typedef {'jid-single'|'jid-multi'} XFormJIDTypes
+ * @typedef {'text-multi'|'text-private'|'text-single'} XFormTextTypes
+ * @typedef {XFormListTypes|XFormJIDTypes|XFormTextTypes|'fixed'|'boolean'|'url'|'hidden'} XFormFieldTypes
+ *
+ * @typedef {Object} XFormField
+ * @property {string} var
+ * @property {string} label
+ * @property {XFormFieldTypes} [type]
+ * @property {string} [text]
+ * @property {string} [value]
+ * @property {boolean} [required]
+ * @property {boolean} [checked]
+ * @property {XFormOption[]} [options]
+ * @property {XFormCaptchaURI} [uri]
+ *
+ * @typedef {'result'|'form'} XFormResponseType
+ *
+ * @typedef {Object} XForm
+ * @property {XFormResponseType} type
+ * @property {string} [title]
+ * @property {string} [instructions]
+ * @property {XFormReportedField[]} [reported]
+ * @property {XFormResultItemField[][]} [items]
+ * @property {XFormField[]} [fields]
+ */
+
+/**
+ * @param {Element} field
+ * @param {Element} stanza
+ * @return {XFormField}
+ */
+function parseXFormField(field, stanza) {
+    const v = field.getAttribute('var');
+    const label = field.getAttribute('label') || '';
+    const type = field.getAttribute('type');
+
+    if (type === 'list-single' || type === 'list-multi') {
+        const values = Array.from(field.querySelectorAll(':scope > value')).map((el) => el?.textContent);
+        const options = Array.from(field.querySelectorAll(':scope > option')).map(
+            (/** @type {HTMLElement} */ option) => {
+                const value = option.querySelector('value')?.textContent;
+                return {
+                    value,
+                    label: option.getAttribute('label'),
+                    selected: values.includes(value),
+                    required: !!field.querySelector('required'),
+                };
+            }
+        );
+        return {
+            type,
+            options,
+            label: field.getAttribute('label'),
+            var: v,
+            required: !!field.querySelector('required'),
+        };
+    } else if (type === 'fixed') {
+        const text = field.querySelector('value')?.textContent;
+        return { text, label, type, var: v };
+    } else if (type === 'jid-multi') {
+        return {
+            type,
+            var: v,
+            label,
+            value: field.querySelector('value')?.textContent,
+            required: !!field.querySelector('required'),
+        };
+    } else if (type === 'boolean') {
+        const value = field.querySelector('value')?.textContent;
+        return {
+            type,
+            var: v,
+            label,
+            checked: ((value === '1' || value === 'true') && true) || false,
+        };
+    } else if (v === 'url') {
+        return {
+            var: v,
+            label,
+            value: field.querySelector('value')?.textContent,
+        };
+    } else if (v === 'username') {
+        return {
+            var: v,
+            label,
+            value: field.querySelector('value')?.textContent,
+            required: !!field.querySelector('required'),
+            type: getInputType(field),
+        };
+    } else if (v === 'password') {
+        return {
+            var: v,
+            label,
+            value: field.querySelector('value')?.textContent,
+            required: !!field.querySelector('required'),
+        };
+    } else if (v === 'ocr') { // Captcha
+        const uri = field.querySelector('uri');
+        const el = sizzle('data[cid="' + uri.textContent.replace(/^cid:/, '') + '"]', stanza)[0];
+        return {
+            label: field.getAttribute('label'),
+            var: v,
+            uri: {
+                type: uri.getAttribute('type'),
+                data: el?.textContent,
+            },
+            required: !!field.querySelector('required'),
+        };
+    } else {
+        return {
+            label,
+            var: v,
+            required: !!field.querySelector('required'),
+            value: field.querySelector('value')?.textContent,
+            type: getInputType(field),
+        };
+    }
+}
+
+/**
+ * @param {Element} field
+ */
+export function getInputType(field) {
+    const type = XFORM_TYPE_MAP[field.getAttribute('type')]
+    if (type == 'text') {
+        const datatypes = field.getElementsByTagNameNS("http://jabber.org/protocol/xdata-validate", "validate");
+        if (datatypes.length === 1) {
+            const datatype = datatypes[0].getAttribute("datatype");
+            return XFORM_VALIDATE_TYPE_MAP[datatype] || type;
+        }
+    }
+    return type;
+}
+
+/**
+* @param {Element} stanza
+* @returns {XForm}
+*/
+export function parseXForm(stanza) {
+    const xs = sizzle(`x[xmlns="${Strophe.NS.XFORM}"]`, stanza);
+    if (xs.length > 1) {
+        log.error(stanza);
+        throw new Error('Invalid stanza');
+    } else if (xs.length === 0) {
+        return null;
+    }
+
+    const x = xs[0];
+    const type = /** @type {XFormResponseType} */ (x.getAttribute('type'));
+    const result = {
+        type,
+        title: x.querySelector('title')?.textContent,
+    };
+
+    if (type === 'result') {
+        const reported = x.querySelector(':scope > reported');
+        const reported_fields = reported?.querySelectorAll(':scope > field');
+        const items = x.querySelectorAll(':scope > item');
+        return /** @type {XForm} */({
+            ...result,
+            reported: /** @type {XFormReportedField[]} */ (Array.from(reported_fields).map(getAttributes)),
+            items: Array.from(items).map((item) => {
+                return Array.from(item.querySelectorAll('field')).map((field) => {
+                    return /** @type {XFormResultItemField} */ ({
+                        ...getAttributes(field),
+                        value: field.querySelector('value')?.textContent ?? '',
+                    });
+                });
+            }),
+        });
+    } else if (type === 'form') {
+        return {
+            ...result,
+            instructions: x.querySelector('instructions')?.textContent,
+            fields: Array.from(x.querySelectorAll('field')).map((field) => parseXFormField(field, stanza)),
+        };
+    } else {
+        throw new Error(`Invalid type in XForm response stanza: ${type}`);
+    }
+}

+ 6 - 5
src/headless/types/index.d.ts

@@ -2,16 +2,17 @@ export { EmojiPicker } from "./plugins/emoji/index.js";
 export { MAMPlaceholderMessage } from "./plugins/mam/index.js";
 export { XMPPStatus } from "./plugins/status/index.js";
 export default converse;
-import api from "./shared/api/index.js";
+import { api } from "./shared/index.js";
 import converse from "./shared/api/public.js";
-import _converse from "./shared/_converse";
-import i18n from "./shared/i18n";
+import { _converse } from "./shared/index.js";
+import { i18n } from "./shared/index.js";
 import log from "./log.js";
 import u from "./utils/index.js";
 export const constants: typeof shared_constants & typeof muc_constants;
-import * as shared_constants from "./shared/constants.js";
+import { parsers } from "./shared/index.js";
+import { constants as shared_constants } from "./shared/index.js";
 import * as muc_constants from "./plugins/muc/constants.js";
-export { api, converse, _converse, i18n, log, u };
+export { api, converse, _converse, i18n, log, u, parsers };
 export { Bookmark, Bookmarks } from "./plugins/bookmarks/index.js";
 export { ChatBox, Message, Messages } from "./plugins/chat/index.js";
 export { MUCMessage, MUCMessages, MUC, MUCOccupant, MUCOccupants } from "./plugins/muc/index.js";

+ 6 - 2
src/headless/types/plugins/adhoc/api.d.ts

@@ -24,9 +24,13 @@ declare namespace _default {
             [k: string]: string;
         }[]): Promise<{
             note: any;
-            sessionid?: string;
+            type?: import("../../shared/parsers.js").XFormResponseType;
+            title?: string;
             instructions?: string;
-            fields?: import("./utils.js").TemplateResult[];
+            reported?: import("../../shared/parsers.js").XFormReportedField[];
+            items?: import("../../shared/parsers.js").XFormResultItemField[][];
+            fields?: import("../../shared/parsers.js").XFormField[];
+            sessionid?: string;
             actions?: string[];
             status: any;
         }>;

+ 7 - 8
src/headless/types/plugins/adhoc/utils.d.ts

@@ -17,21 +17,20 @@ export function parseForCommands(stanza: Element): AdHocCommand[];
  * @property {string} text
  * @property {'info'|'warn'|'error'} type
  *
- * @typedef {Object} AdHocCommandResult
+ * @typedef {Object} AdHocCommandAttrs
  * @property {string} sessionid
- * @property {string} [instructions]
- * @property {TemplateResult[]} [fields]
  * @property {string[]} [actions]
  * @property {AdHocCommandResultNote} [note]
+ *
+ * @typedef {XForm & AdHocCommandAttrs} AdHocCommandResult
  */
 /**
  * Given a "result" IQ stanza containing the outcome of an Ad-hoc command that
  * was executed, parse it and return the values as a JSON object.
  * @param {Element} iq
- * @param {string} [jid]
  * @returns {AdHocCommandResult}
  */
-export function parseCommandResult(iq: Element, jid?: string): AdHocCommandResult;
+export function parseCommandResult(iq: Element): AdHocCommandResult;
 export type AdHocCommand = {
     action: string;
     node: string;
@@ -42,12 +41,12 @@ export type AdHocCommandResultNote = {
     text: string;
     type: 'info' | 'warn' | 'error';
 };
-export type AdHocCommandResult = {
+export type AdHocCommandAttrs = {
     sessionid: string;
-    instructions?: string;
-    fields?: TemplateResult[];
     actions?: string[];
     note?: AdHocCommandResultNote;
 };
+export type AdHocCommandResult = XForm & AdHocCommandAttrs;
 export type TemplateResult = import('lit').TemplateResult;
+export type XForm = import('../../shared/parsers').XForm;
 //# sourceMappingURL=utils.d.ts.map

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

@@ -1,6 +1,6 @@
 export function isChatRoom(model: any): boolean;
 export function shouldCreateGroupchatMessage(attrs: any): any;
-export function occupantsComparator(occupant1: any, occupant2: any): 1 | 0 | -1;
+export function occupantsComparator(occupant1: any, occupant2: any): 0 | 1 | -1;
 export function registerDirectInvitationHandler(): void;
 export function disconnectChatRooms(): any;
 export function onWindowStateChanged(): Promise<void>;

+ 19 - 0
src/headless/types/shared/constants.d.ts

@@ -51,4 +51,23 @@ export namespace KEYCODES {
     const META: number;
     const META_RIGHT: number;
 }
+export const XFORM_TYPE_MAP: {
+    'text-private': string;
+    'text-single': string;
+    fixed: string;
+    boolean: string;
+    hidden: string;
+    'jid-multi': string;
+    'list-single': string;
+    'list-multi': string;
+};
+export const XFORM_VALIDATE_TYPE_MAP: {
+    'xs:anyURI': string;
+    'xs:byte': string;
+    'xs:date': string;
+    'xs:dateTime': string;
+    'xs:int': string;
+    'xs:integer': string;
+    'xs:time': string;
+};
 //# sourceMappingURL=constants.d.ts.map

+ 7 - 0
src/headless/types/shared/index.d.ts

@@ -0,0 +1,7 @@
+import _converse from "./_converse";
+import api from "./api/index.js";
+import * as constants from "./constants.js";
+import i18n from "./i18n";
+import * as parsers from "./parsers.js";
+export { _converse, api, constants, i18n, parsers };
+//# sourceMappingURL=index.d.ts.map

+ 81 - 17
src/headless/types/shared/parsers.d.ts

@@ -15,26 +15,38 @@ export function getEncryptionAttributes(stanza: Element): {
     encryption_namespace: any;
 };
 /**
- * @private
- * @param { Element } stanza - The message stanza
- * @param { Element } original_stanza - The original stanza, that contains the
+ * @param {Element} stanza - The message stanza
+ * @param {Element} original_stanza - The original stanza, that contains the
  *  message stanza, if it was contained, otherwise it's the message stanza itself.
- * @returns { Object }
+ * @returns {Object}
  */
 export function getRetractionAttributes(stanza: Element, original_stanza: Element): any;
-export function getCorrectionAttributes(stanza: any, original_stanza: any): {
+/**
+ * @param {Element} stanza
+ * @param {Element} original_stanza
+ */
+export function getCorrectionAttributes(stanza: Element, original_stanza: Element): {
     replace_id: any;
     edited: string;
 } | {
     replace_id?: undefined;
     edited?: undefined;
 };
-export function getOpenGraphMetadata(stanza: any): any;
-export function getSpoilerAttributes(stanza: any): {
+/**
+ * @param {Element} stanza
+ */
+export function getOpenGraphMetadata(stanza: Element): any;
+/**
+ * @param {Element} stanza
+ */
+export function getSpoilerAttributes(stanza: Element): {
     is_spoiler: boolean;
     spoiler_hint: any;
 };
-export function getOutOfBandAttributes(stanza: any): {
+/**
+ * @param {Element} stanza
+ */
+export function getOutOfBandAttributes(stanza: Element): {
     oob_url: any;
     oob_desc: any;
 } | {
@@ -43,8 +55,7 @@ export function getOutOfBandAttributes(stanza: any): {
 };
 /**
  * Returns the human readable error message contained in a `groupchat` message stanza of type `error`.
- * @private
- * @param { Element } stanza - The message stanza
+ * @param {Element} stanza - The message stanza
  */
 export function getErrorAttributes(stanza: Element): {
     is_error: boolean;
@@ -60,7 +71,7 @@ export function getErrorAttributes(stanza: Element): {
 /**
  * Given a message stanza, find and return any XEP-0372 references
  * @param {Element} stanza - The message stanza
- * @returns { Reference }
+ * @returns {Reference}
  */
 export function getReferences(stanza: Element): any;
 /**
@@ -69,18 +80,20 @@ export function getReferences(stanza: Element): any;
 export function getReceiptId(stanza: Element): any;
 /**
  * Determines whether the passed in stanza is a XEP-0280 Carbon
- * @private
- * @param { Element } stanza - The message stanza
- * @returns { Boolean }
+ * @param {Element} stanza - The message stanza
+ * @returns {Boolean}
  */
 export function isCarbon(stanza: Element): boolean;
 /**
  * Returns the XEP-0085 chat state contained in a message stanza
- * @private
- * @param { Element } stanza - The message stanza
+ * @param {Element} stanza - The message stanza
  */
 export function getChatState(stanza: Element): any;
-export function isValidReceiptRequest(stanza: any, attrs: any): any;
+/**
+ * @param {Element} stanza
+ * @param {Object} attrs
+ */
+export function isValidReceiptRequest(stanza: Element, attrs: any): any;
 /**
  * Check whether the passed-in stanza is a forwarded message that is "bare",
  * i.e. it's not forwarded as part of a larger protocol, like MAM.
@@ -109,6 +122,15 @@ export function isServerMessage(stanza: Element): boolean;
  * @returns {boolean}
  */
 export function isArchived(original_stanza: Element): boolean;
+/**
+ * @param {Element} field
+ */
+export function getInputType(field: Element): any;
+/**
+* @param {Element} stanza
+* @returns {XForm}
+*/
+export function parseXForm(stanza: Element): XForm;
 export class StanzaParseError extends Error {
     /**
      * @param {string} message
@@ -117,5 +139,47 @@ export class StanzaParseError extends Error {
     constructor(message: string, stanza: Element);
     stanza: Element;
 }
+export type XFormReportedField = {
+    var: string;
+    label: string;
+};
+export type XFormResultItemField = {
+    var: string;
+    value: string;
+};
+export type XFormOption = {
+    value: string;
+    label: string;
+    selected: boolean;
+    required: boolean;
+};
+export type XFormCaptchaURI = {
+    type: string;
+    data: string;
+};
+export type XFormListTypes = 'list-single' | 'list-multi';
+export type XFormJIDTypes = 'jid-single' | 'jid-multi';
+export type XFormTextTypes = 'text-multi' | 'text-private' | 'text-single';
+export type XFormFieldTypes = XFormListTypes | XFormJIDTypes | XFormTextTypes | 'fixed' | 'boolean' | 'url' | 'hidden';
+export type XFormField = {
+    var: string;
+    label: string;
+    type?: XFormFieldTypes;
+    text?: string;
+    value?: string;
+    required?: boolean;
+    checked?: boolean;
+    options?: XFormOption[];
+    uri?: XFormCaptchaURI;
+};
+export type XFormResponseType = 'result' | 'form';
+export type XForm = {
+    type: XFormResponseType;
+    title?: string;
+    instructions?: string;
+    reported?: XFormReportedField[];
+    items?: XFormResultItemField[][];
+    fields?: XFormField[];
+};
 export type Reference = any;
 //# sourceMappingURL=parsers.d.ts.map

+ 39 - 26
src/plugins/adhoc-views/adhoc-commands.js

@@ -24,18 +24,18 @@ const { Strophe, sizzle } = converse.env;
  */
 
 export default class AdHocCommands extends CustomElement {
-    static get properties () {
+    static get properties() {
         return {
-            'alert': { type: String },
-            'alert_type': { type: String },
-            'commands': { type: Array },
-            'fetching': { type: Boolean },
-            'showform': { type: String },
-            'view': { type: String },
+            alert: { type: String },
+            alert_type: { type: String },
+            commands: { type: Array },
+            fetching: { type: Boolean },
+            showform: { type: String },
+            view: { type: String },
         };
     }
 
-    constructor () {
+    constructor() {
         super();
         this.view = 'choose-service';
         this.fetching = false;
@@ -50,7 +50,7 @@ export default class AdHocCommands extends CustomElement {
     /**
      * @param {SubmitEvent} ev
      */
-    async fetchCommands (ev) {
+    async fetchCommands(ev) {
         ev.preventDefault();
 
         if (!(ev.target instanceof HTMLFormElement)) {
@@ -93,9 +93,12 @@ export default class AdHocCommands extends CustomElement {
         }
     }
 
-    async toggleCommandForm (ev) {
+    /**
+     * @param {MouseEvent} ev
+     */
+    async toggleCommandForm(ev) {
         ev.preventDefault();
-        const node = ev.target.getAttribute('data-command-node');
+        const node = /** @type {Element} */ (ev.target).getAttribute('data-command-node');
         const cmd = this.commands.filter((c) => c.node === node)[0];
         const { jid } = cmd;
 
@@ -104,11 +107,8 @@ export default class AdHocCommands extends CustomElement {
             this.requestUpdate();
         } else {
             try {
-                const { sessionid, instrucions, fields, actions, note, status } = await api.adhoc.fetchCommandForm(
-                    jid,
-                    node
-                );
-                Object.assign(cmd, { sessionid, instrucions, fields, actions, note, status });
+                const xform = await api.adhoc.fetchCommandForm(jid, node);
+                Object.assign(cmd, xform);
             } catch (e) {
                 if (e === null) {
                     log.error(`Error: timeout while trying to execute command for ${jid}`);
@@ -123,25 +123,32 @@ export default class AdHocCommands extends CustomElement {
         }
     }
 
-    executeAction (ev) {
+    /**
+     * @param {MouseEvent} ev
+     */
+    executeAction(ev) {
         ev.preventDefault();
-        const action = ev.target.getAttribute('data-action');
+        const form = /** @type {HTMLFormElement} */ (ev.target);
+        const action = form.getAttribute('data-action');
 
         if (['execute', 'next', 'prev', 'complete'].includes(action)) {
-            this.runCommand(ev.target.form, action);
+            this.runCommand(form.form, /** @type {AdHocCommandAction} */ (action));
         } else {
             log.error(`Unknown action: ${action}`);
         }
     }
 
-    clearCommand (cmd) {
+    /**
+     * @param {AdHocCommandUIProps} cmd
+     */
+    clearCommand(cmd) {
         delete cmd.alert;
         delete cmd.instructions;
         delete cmd.sessionid;
         delete cmd.alert_type;
         delete cmd.status;
         cmd.fields = [];
-        cmd.acions = [];
+        cmd.actions = [];
         this.showform = '';
     }
 
@@ -149,7 +156,7 @@ export default class AdHocCommands extends CustomElement {
      * @param {HTMLFormElement} form
      * @param {AdHocCommandAction} action
      */
-    async runCommand (form, action) {
+    async runCommand(form, action) {
         const form_data = new FormData(form);
         const jid = /** @type {string} */ (form_data.get('command_jid')).trim();
         const node = /** @type {string} */ (form_data.get('command_node')).trim();
@@ -162,9 +169,12 @@ export default class AdHocCommands extends CustomElement {
             action === 'prev'
                 ? []
                 : sizzle(':input:not([type=button]):not([type=submit])', form)
-                      .filter((i) => !['command_jid', 'command_node'].includes(i.getAttribute('name')))
+                      .filter(
+                          /** @param {HTMLInputElement} i */
+                          (i) => !['command_jid', 'command_node'].includes(i.getAttribute('name'))
+                      )
                       .map(getNameAndValue)
-                      .filter((n) => n);
+                      .filter(/** @param {unknown} [n] */ (n) => n);
 
         const response = await api.adhoc.runCommand(jid, cmd.sessionid, cmd.node, action, inputs);
 
@@ -196,12 +206,15 @@ export default class AdHocCommands extends CustomElement {
         this.requestUpdate();
     }
 
-    async cancel (ev) {
+    /**
+     * @param {MouseEvent} ev
+     */
+    async cancel(ev) {
         ev.preventDefault();
         this.showform = '';
         this.requestUpdate();
 
-        const form_data = new FormData(ev.target.form);
+        const form_data = new FormData(/** @type {HTMLFormElement} */ (ev.target).form);
         const jid = /** @type {string} */ (form_data.get('command_jid')).trim();
         const node = /** @type {string} */ (form_data.get('command_node')).trim();
 

+ 22 - 2
src/plugins/adhoc-views/templates/ad-hoc-command-form.js

@@ -1,9 +1,11 @@
 /**
+ * @typedef {import('lit').TemplateResult} TemplateResult
  * @typedef {import('../adhoc-commands').default} AdHocCommands
  * @typedef {import('../adhoc-commands').AdHocCommandUIProps} AdHocCommandUIProps
  */
 import { html } from 'lit';
 import { __ } from 'i18n';
+import { xFormField2TemplateResult } from 'utils/html.js';
 
 const ACTION_MAP = {
     execute: __('Execute'),
@@ -18,6 +20,24 @@ const NOTE_ALERT_MAP = {
     'error': 'danger',
 };
 
+/**
+ * @param {AdHocCommandUIProps} command
+ */
+function tplReportedTable (command) {
+    return html`
+        <table class="table">
+            <thead class="thead-light">
+                ${command.reported?.map((r) => html`<th scope="col" data-var="${r.var}">${r.label}</th>`)}
+            </thead>
+            <tbody>
+                ${command.items?.map(
+                    (fields) => html`<tr>${fields.map((f) => html`<td data-var="${f.var}">${f.value}</td>`)
+                }</tr>`)}
+            </tbody>
+        </table>
+    `;
+}
+
 /**
  * @param {AdHocCommands} el
  * @param {AdHocCommandUIProps} command
@@ -43,9 +63,9 @@ export default (el, command) => {
                 <fieldset class="form-group">
                     <input type="hidden" name="command_node" value="${command.node}" />
                     <input type="hidden" name="command_jid" value="${command.jid}" />
-
                     ${command.instructions ? html`<p class="form-instructions">${command.instructions}</p>` : ''}
-                    ${command.fields ?? []}
+                    ${command.type === 'result' ? tplReportedTable(command) : ''}
+                    ${command.fields?.map(f => xFormField2TemplateResult(f), { domain: command.jid }) ?? ''}
                 </fieldset>
                 ${command.actions?.length
                     ? html` <fieldset>

+ 141 - 2
src/plugins/adhoc-views/tests/adhoc.js

@@ -19,7 +19,14 @@ describe("Ad-hoc commands", function () {
         adhoc_form.querySelector('input[name="jid"]').value = entity_jid;
         adhoc_form.querySelector('input[type="submit"]').click();
 
-        await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info');
+        await mock.waitUntilDiscoConfirmed(
+            _converse,
+            entity_jid,
+            [],
+            ['http://jabber.org/protocol/commands'],
+            [],
+            'info'
+        );
 
         let sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#items"]`;
         let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
@@ -147,7 +154,14 @@ describe("Ad-hoc commands", function () {
         adhoc_form.querySelector('input[name="jid"]').value = entity_jid;
         adhoc_form.querySelector('input[type="submit"]').click();
 
-        await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info');
+        await mock.waitUntilDiscoConfirmed(
+            _converse,
+            entity_jid,
+            [],
+            ['http://jabber.org/protocol/commands'],
+            [],
+            'info'
+        );
 
         let sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#items"]`;
         let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
@@ -211,6 +225,131 @@ describe("Ad-hoc commands", function () {
         const inputs = form.querySelectorAll('input[type="button"]');
         expect(inputs.length).toBe(0);
     }));
+
+    it("may return reported fields, which are not editable and are presented in a table",
+            mock.initConverse([], {}, async (_converse) => {
+        const { api } = _converse;
+        const entity_jid = 'muc.montague.lit';
+        const { IQ_stanzas } = _converse.api.connection.get();
+
+        const jid = _converse.session.get('jid');
+
+        const modal = await api.modal.show('converse-user-settings-modal');
+        await u.waitUntil(() => u.isVisible(modal));
+        modal.querySelector('#commands-tab').click();
+
+        const adhoc_form = modal.querySelector('converse-adhoc-commands');
+        await u.waitUntil(() => u.isVisible(adhoc_form));
+
+        adhoc_form.querySelector('input[name="jid"]').value = entity_jid;
+        adhoc_form.querySelector('input[type="submit"]').click();
+
+        await mock.waitUntilDiscoConfirmed(
+            _converse,
+            entity_jid,
+            [],
+            ['http://jabber.org/protocol/commands'],
+            [],
+            'info'
+        );
+
+        let sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#items"]`;
+        let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+            <iq type="result"
+                id="${iq.getAttribute("id")}"
+                to="${_converse.jid}"
+                from="${entity_jid}"
+                xmlns="jabber:client">
+            <query xmlns="http://jabber.org/protocol/disco#items"
+                    node="http://jabber.org/protocol/commands">
+                <item node="list" name="List services" jid="${entity_jid}"/>
+            </query>
+        </iq>`));
+
+        const heading = await u.waitUntil(() => adhoc_form.querySelector('.list-group-item.active'));
+        expect(heading.textContent).toBe('Commands found:');
+
+        const items = adhoc_form.querySelectorAll('.list-group-item:not(.active)');
+        expect(items.length).toBe(1);
+        expect(items[0].textContent.trim()).toBe('List services');
+        items[0].querySelector('a').click();
+
+        sel = `iq[to="${entity_jid}"][type="set"] command`;
+        iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+        expect(Strophe.serialize(iq)).toBe(
+            `<iq id="${iq.getAttribute("id")}" to="${entity_jid}" type="set" xmlns="jabber:client">`+
+                `<command action="execute" node="list" xmlns="http://jabber.org/protocol/commands"/>`+
+            `</iq>`
+        );
+
+        _converse.api.connection.get()._dataRecv(
+            mock.createRequest(stx`
+            <iq type="result" from="${entity_jid}" to="${jid}" id="${iq.getAttribute("id")}" xmlns="jabber:client">
+                <command xmlns="http://jabber.org/protocol/commands"
+                        sessionid="list:20020923T213616Z-700"
+                        node="list"
+                        status="completed">
+                    <x xmlns="jabber:x:data" type="result">
+                    <title>Available Services</title>
+                    <reported>
+                        <field var="service" label="Service"/>
+                        <field var="runlevel-1" label="Single-User mode"/>
+                        <field var="runlevel-2" label="Non-Networked Multi-User mode"/>
+                        <field var="runlevel-3" label="Full Multi-User mode"/>
+                        <field var="runlevel-5" label="X-Window mode"/>
+                    </reported>
+                    <item>
+                        <field var="service"><value>httpd</value></field>
+                        <field var="runlevel-1"><value>off</value></field>
+                        <field var="runlevel-2"><value>off</value></field>
+                        <field var="runlevel-3"><value>on</value></field>
+                        <field var="runlevel-5"><value>on</value></field>
+                    </item>
+                    <item>
+                        <field var="service"><value>postgresql</value></field>
+                        <field var="runlevel-1"><value>off</value></field>
+                        <field var="runlevel-2"><value>off</value></field>
+                        <field var="runlevel-3"><value>on</value></field>
+                        <field var="runlevel-5"><value>off</value></field>
+                    </item>
+                    <item>
+                        <field var="service"><value>jabberd</value></field>
+                        <field var="runlevel-1"><value>off</value></field>
+                        <field var="runlevel-2"><value>on</value></field>
+                        <field var="runlevel-3"><value>on</value></field>
+                        <field var="runlevel-5"><value>on</value></field>
+                    </item>
+                    </x>
+                </command>
+            </iq>`
+        ));
+
+        const table = await u.waitUntil(() => adhoc_form.querySelector('form form table'));
+        expect(table).toBeDefined();
+
+        const headings = table.querySelectorAll('thead th');
+        expect(headings.length).toBe(5);
+
+        expect(Array.from(headings).map(h => h.textContent)).toEqual([
+            'Service',
+            'Single-User mode',
+            'Non-Networked Multi-User mode',
+            'Full Multi-User mode',
+            'X-Window mode'
+        ]);
+
+        const rows = table.querySelectorAll('tr');
+        expect(rows.length).toBe(3);
+        expect(Array.from(rows[0].querySelectorAll('td')).map(h => h.textContent))
+            .toEqual(['httpd', 'off', 'off', 'on', 'on']);
+        expect(Array.from(rows[1].querySelectorAll('td')).map(h => h.textContent))
+            .toEqual(['postgresql', 'off', 'off', 'on', 'off']);
+        expect(Array.from(rows[2].querySelectorAll('td')).map(h => h.textContent))
+            .toEqual(['jabberd', 'off', 'on', 'on', 'on']);
+    }));
 });
 
 describe("Ad-hoc commands consisting of multiple steps", function () {

+ 31 - 25
src/plugins/muc-views/templates/muc-config-form.js

@@ -1,51 +1,57 @@
 import tplSpinner from 'templates/spinner.js';
 import { __ } from 'i18n';
-import { api, converse } from "@converse/headless";
-import { html } from "lit";
+import { api, converse, parsers } from '@converse/headless';
+import { html } from 'lit';
 
-const { sizzle } = converse.env;
 const u = converse.env.utils;
 
 export default (o) => {
     const whitelist = api.settings.get('roomconfig_whitelist');
     const config_stanza = o.model.session.get('config_stanza');
-    let fields = [];
+    let fieldTemplates = [];
     let instructions = '';
-    let title;
+    let title = __('Loading configuration form');
+
     if (config_stanza) {
         const stanza = u.toStanza(config_stanza);
-        fields = sizzle('field', stanza);
+        let { fields } = parsers.parseXForm(stanza);
+
         if (whitelist.length) {
-            fields = fields.filter(f => whitelist.includes(f.getAttribute('var')));
+            fields = fields.filter((f) => whitelist.includes(f.var));
         }
-        const password_protected = o.model.features.get('passwordprotected');
         const options = {
-            'new_password': !password_protected,
-            'fixed_username': o.model.get('jid')
+            new_password: !o.model.features.get('passwordprotected'),
+            fixed_username: o.model.get('jid'),
         };
-        fields = fields.map(f => u.xForm2TemplateResult(f, stanza, options));
+        fieldTemplates = fields.map((f) => u.xFormField2TemplateResult(f, stanza, options));
         instructions = stanza.querySelector('instructions')?.textContent;
         title = stanza.querySelector('title')?.textContent;
-    } else {
-        title = __('Loading configuration form');
     }
+
     const i18n_save = __('Save');
     const i18n_cancel = __('Cancel');
     return html`
-        <form class="converse-form chatroom-form ${fields.length ? '' : 'converse-form--spinner'}"
-                autocomplete="off"
-                @submit=${o.submitConfigForm}>
-
+        <form
+            class="converse-form chatroom-form ${fieldTemplates.length ? '' : 'converse-form--spinner'}"
+            autocomplete="off"
+            @submit=${o.submitConfigForm}
+        >
             <fieldset class="form-group">
                 <legend class="centered">${title}</legend>
-                ${ (title !== instructions) ? html`<p class="form-help">${instructions}</p>` : '' }
-                ${ fields.length ? fields : tplSpinner({'classes': 'hor_centered'}) }
+                ${title !== instructions ? html`<p class="form-help">${instructions}</p>` : ''}
+                ${fieldTemplates.length ? fieldTemplates : tplSpinner({ 'classes': 'hor_centered' })}
             </fieldset>
-            ${ fields.length ? html`
-                <fieldset>
-                    <input type="submit" class="btn btn-primary" value="${i18n_save}">
-                    <input type="button" class="btn btn-secondary button-cancel" value="${i18n_cancel}" @click=${o.closeConfigForm}>
-                </fieldset>` : '' }
+            ${fieldTemplates.length
+                ? html` <fieldset>
+                      <input type="submit" class="btn btn-primary" value="${i18n_save}" />
+                      <input
+                          type="button"
+                          class="btn btn-secondary button-cancel"
+                          value="${i18n_cancel}"
+                          @click=${o.closeConfigForm}
+                      />
+                  </fieldset>`
+                : ''}
         </form>
     `;
-}
+};

+ 9 - 4
src/plugins/register/panel.js

@@ -1,7 +1,7 @@
 /**
  * @typedef {import('strophe.js').Request} Request
  */
-import { _converse, api, converse, log, constants, u } from "@converse/headless";
+import { _converse, api, converse, log, constants, u, parsers } from "@converse/headless";
 import tplFormInput from "templates/form_input.js";
 import tplFormUrl from "templates/form_url.js";
 import tplFormUsername from "templates/form_username.js";
@@ -276,11 +276,13 @@ class RegisterPanel extends CustomElement {
         return [...input_fields, ...urls];
     }
 
+    /**
+     * @param {Element} stanza
+     */
     getFormFields (stanza) {
         if (this.form_type === 'xform') {
-            return Array.from(stanza.querySelectorAll('field')).map(field =>
-                u.xForm2TemplateResult(field, stanza, {'domain': this.domain})
-            );
+            const { fields } = parsers.parseXForm(stanza);
+            return fields?.map((f) => u.xFormField2TemplateResult(f, {'domain': this.domain})) ?? [];
         } else {
             return this.getLegacyFormFields();
         }
@@ -391,6 +393,9 @@ class RegisterPanel extends CustomElement {
         this.form_type = 'legacy';
     }
 
+    /**
+     * @param {Element} xform
+     */
     _setFieldsFromXForm (xform) {
         this.title = xform.querySelector('title')?.textContent ?? '';
         this.instructions = xform.querySelector('instructions')?.textContent ?? '';

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

@@ -17,6 +17,7 @@
     @import "bootstrap/scss/type";
     @import "bootstrap/scss/images";
     @import "bootstrap/scss/grid";
+    @import "bootstrap/scss/tables";
     @import "bootstrap/scss/transitions";
     @import "bootstrap/scss/nav";
     @import "bootstrap/scss/alert";

+ 16 - 4
src/types/plugins/adhoc-views/adhoc-commands.d.ts

@@ -40,16 +40,28 @@ export default class AdHocCommands extends CustomElement {
     fetchCommands(ev: SubmitEvent): Promise<void>;
     alert_type: string;
     alert: any;
-    toggleCommandForm(ev: any): Promise<void>;
-    executeAction(ev: any): void;
-    clearCommand(cmd: any): void;
+    /**
+     * @param {MouseEvent} ev
+     */
+    toggleCommandForm(ev: MouseEvent): Promise<void>;
+    /**
+     * @param {MouseEvent} ev
+     */
+    executeAction(ev: MouseEvent): void;
+    /**
+     * @param {AdHocCommandUIProps} cmd
+     */
+    clearCommand(cmd: AdHocCommandUIProps): void;
     /**
      * @param {HTMLFormElement} form
      * @param {AdHocCommandAction} action
      */
     runCommand(form: HTMLFormElement, action: AdHocCommandAction): Promise<void>;
     note: any;
-    cancel(ev: any): Promise<void>;
+    /**
+     * @param {MouseEvent} ev
+     */
+    cancel(ev: MouseEvent): Promise<void>;
 }
 export type AdHocCommand = import('@converse/headless/types/plugins/adhoc/utils').AdHocCommand;
 export type AdHocCommandResult = import('@converse/headless/types/plugins/adhoc/utils').AdHocCommandResult;

+ 1 - 0
src/types/plugins/adhoc-views/templates/ad-hoc-command-form.d.ts

@@ -1,5 +1,6 @@
 declare function _default(el: AdHocCommands, command: AdHocCommandUIProps): import("lit-html").TemplateResult<1>;
 export default _default;
+export type TemplateResult = import('lit').TemplateResult;
 export type AdHocCommands = import('../adhoc-commands').default;
 export type AdHocCommandUIProps = import('../adhoc-commands').AdHocCommandUIProps;
 //# sourceMappingURL=ad-hoc-command-form.d.ts.map

+ 20 - 3
src/types/utils/html.d.ts

@@ -42,7 +42,11 @@ export function removeClass(className: string, el: Element): Element;
  * @param { Element } el
  */
 export function removeElement(el: Element): Element;
-export function ancestor(el: any, selector: any): any;
+/**
+ * @param {HTMLElement} el
+ * @param {String} selector
+ */
+export function ancestor(el: HTMLElement, selector: string): HTMLElement;
 /**
  * @param {string} url
  */
@@ -50,8 +54,8 @@ export function getHyperlinkTemplate(url: string): string | import("lit-html").T
 /**
  * Shows/expands an element by sliding it out of itself
  * @method slideOut
- * @param { HTMLElement } el - The HTML string
- * @param { Number } duration - The duration amount in milliseconds
+ * @param {HTMLElement} el - The HTML string
+ * @param {Number} duration - The duration amount in milliseconds
  */
 export function slideOut(el: HTMLElement, duration?: number): Promise<any>;
 /**
@@ -60,6 +64,18 @@ export function slideOut(el: HTMLElement, duration?: number): Promise<any>;
  * @param {Number} duration - The duration amount in milliseconds
  */
 export function slideIn(el: HTMLElement, duration?: number): Promise<any>;
+/**
+ * Takes an XML field in XMPP XForm (XEP-004: Data Forms) format returns a
+ * [TemplateResult](https://lit.polymer-project.org/api/classes/_lit_html_.templateresult.html).
+ * @param {XFormField} xfield - the field to convert
+ * @param {Object} options
+ * @returns {TemplateResult}
+ */
+export function xFormField2TemplateResult(xfield: XFormField, options?: any): TemplateResult;
+/**
+ * @param {Element} field
+ */
+export function getInputType(field: Element): any;
 /**
  * Takes an XML field in XMPP XForm (XEP-004: Data Forms) format returns a
  * [TemplateResult](https://lit.polymer-project.org/api/classes/_lit_html_.templateresult.html).
@@ -77,5 +93,6 @@ export function xForm2TemplateResult(field: HTMLElement, stanza: Element, option
 export function getOuterWidth(el: HTMLElement, include_margin?: boolean): number;
 export default u;
 export type TemplateResult = import('lit').TemplateResult;
+export type XFormField = import('@converse/headless/types/shared/parsers').XFormField;
 import { u } from "@converse/headless";
 //# sourceMappingURL=html.d.ts.map

+ 53 - 112
src/utils/html.js

@@ -3,10 +3,11 @@
  * @license Mozilla Public License (MPLv2)
  * @description This is the DOM/HTML utilities module.
  * @typedef {import('lit').TemplateResult} TemplateResult
+ * @typedef {import('@converse/headless/types/shared/parsers').XFormField} XFormField
  */
 import { render } from 'lit';
 import { Builder, Stanza } from 'strophe.js';
-import { api, converse, log, u } from '@converse/headless';
+import { api, converse, constants, log, u } from '@converse/headless';
 import tplAudio from 'templates/audio.js';
 import tplFile from 'templates/file.js';
 import tplFormCaptcha from '../templates/form_captcha.js';
@@ -22,31 +23,10 @@ import tplVideo from 'templates/video.js';
 
 const { sizzle, Strophe } = converse.env;
 const { getURI, isAudioURL, isImageURL, isVideoURL, isValidURL, queryChildren } = u;
+const { XFORM_TYPE_MAP, XFORM_VALIDATE_TYPE_MAP } = constants;
 
 const APPROVED_URL_PROTOCOLS = ['http', 'https', 'xmpp', 'mailto'];
 
-const XFORM_TYPE_MAP = {
-    'text-private': 'password',
-    'text-single': 'text',
-    'fixed': 'label',
-    'boolean': 'checkbox',
-    'hidden': 'hidden',
-    'jid-multi': 'textarea',
-    'list-single': 'dropdown',
-    'list-multi': 'dropdown'
-};
-
-const XFORM_VALIDATE_TYPE_MAP = {
-    'xs:anyURI': 'url',
-    'xs:byte': 'number',
-    'xs:date': 'date',
-    'xs:dateTime': 'datetime',
-    'xs:int': 'number',
-    'xs:integer': 'number',
-    'xs:time': 'time',
-}
-
-
 const EMPTY_TEXT_REGEX = /\s*\n\s*/
 
 /**
@@ -149,18 +129,9 @@ export function getNameAndValue(field) {
     return { name, value };
 }
 
-function getInputType(field) {
-    const type = XFORM_TYPE_MAP[field.getAttribute('type')]
-    if (type == 'text') {
-        const datatypes = field.getElementsByTagNameNS("http://jabber.org/protocol/xdata-validate", "validate");
-        if (datatypes.length === 1) {
-            const datatype = datatypes[0].getAttribute("datatype");
-            return XFORM_VALIDATE_TYPE_MAP[datatype] || type;
-        }
-    }
-    return type;
-}
-
+/**
+ * @param {HTMLElement} el
+ */
 function slideOutWrapup (el) {
     /* Wrapup function for slideOut. */
     el.removeAttribute('data-slider-marker');
@@ -293,6 +264,10 @@ function hideElement (el) {
     return el;
 }
 
+/**
+ * @param {HTMLElement} el
+ * @param {String} selector
+ */
 export function ancestor (el, selector) {
     let parent = el;
     while (parent !== null && !sizzle.matchesSelector(parent, selector)) {
@@ -304,8 +279,8 @@ export function ancestor (el, selector) {
 /**
  * Return the element's siblings until one matches the selector.
  * @method u#nextUntil
- * @param { HTMLElement } el
- * @param { String } selector
+ * @param {HTMLElement} el
+ * @param {String} selector
  */
 function nextUntil (el, selector) {
     const matches = [];
@@ -360,8 +335,8 @@ export function getHyperlinkTemplate (url) {
 /**
  * Shows/expands an element by sliding it out of itself
  * @method slideOut
- * @param { HTMLElement } el - The HTML string
- * @param { Number } duration - The duration amount in milliseconds
+ * @param {HTMLElement} el - The HTML string
+ * @param {Number} duration - The duration amount in milliseconds
  */
 export function slideOut (el, duration = 200) {
     return new Promise((resolve, reject) => {
@@ -486,95 +461,61 @@ function isVisible (el) {
 /**
  * Takes an XML field in XMPP XForm (XEP-004: Data Forms) format returns a
  * [TemplateResult](https://lit.polymer-project.org/api/classes/_lit_html_.templateresult.html).
- * @method u#xForm2TemplateResult
- * @param {HTMLElement} field - the field to convert
- * @param {Element} stanza - the containing stanza
+ * @param {XFormField} xfield - the field to convert
  * @param {Object} options
  * @returns {TemplateResult}
  */
-export function xForm2TemplateResult (field, stanza, options={}) {
-    if (field.getAttribute('type') === 'list-single' || field.getAttribute('type') === 'list-multi') {
-        const values = queryChildren(field, 'value').map(el => el?.textContent);
-        const options = queryChildren(field, 'option').map((/** @type {HTMLElement} */option) => {
-            const value = option.querySelector('value')?.textContent;
-            return {
-                'value': value,
-                'label': option.getAttribute('label'),
-                'selected': values.includes(value),
-                'required': !!field.querySelector('required')
-            };
-        });
+export function xFormField2TemplateResult (xfield, options={}) {
+    if (xfield['type'] === 'list-single' || xfield['type'] === 'list-multi') {
         return tplFormSelect({
-            options,
-            'id': u.getUniqueId(),
-            'label': field.getAttribute('label'),
-            'multiple': field.getAttribute('type') === 'list-multi',
-            'name': field.getAttribute('var'),
-            'required': !!field.querySelector('required')
+            id: u.getUniqueId(),
+            ...xfield,
+            multiple: xfield.type === 'list-multi',
+            name: xfield.var,
         });
-    } else if (field.getAttribute('type') === 'fixed') {
-        const text = field.querySelector('value')?.textContent;
-        return tplFormHelp({ text });
-    } else if (field.getAttribute('type') === 'jid-multi') {
+    } else if (xfield['type'] === 'fixed') {
+        return tplFormHelp(xfield);
+    } else if (xfield['type'] === 'jid-multi') {
         return tplFormTextarea({
-            'name': field.getAttribute('var'),
-            'label': field.getAttribute('label') || '',
-            'value': field.querySelector('value')?.textContent,
-            'required': !!field.querySelector('required')
+            name: xfield.var,
+            ...xfield
         });
-    } else if (field.getAttribute('type') === 'boolean') {
-        const value = field.querySelector('value')?.textContent;
+    } else if (xfield['type'] === 'boolean') {
         return tplFormCheckbox({
-            'id': u.getUniqueId(),
-            'name': field.getAttribute('var'),
-            'label': field.getAttribute('label') || '',
-            'checked': ((value === '1' || value === 'true') && 'checked="1"') || ''
-        });
-    } else if (field.getAttribute('var') === 'url') {
-        return tplFormUrl({
-            'label': field.getAttribute('label') || '',
-            'value': field.querySelector('value')?.textContent
+            id: u.getUniqueId(),
+            name: xfield.var,
+            ...xfield
         });
-    } else if (field.getAttribute('var') === 'username') {
+    } else if (xfield['var'] === 'url') {
+        return tplFormUrl(xfield);
+    } else if (xfield['var'] === 'username') {
         return tplFormUsername({
-            'domain': ' @' + options.domain,
-            'name': field.getAttribute('var'),
-            'type': getInputType(field),
-            'label': field.getAttribute('label') || '',
-            'value': field.querySelector('value')?.textContent,
-            'required': !!field.querySelector('required')
+            domain: options.domain ? ' @' + options.domain : '',
+            name: xfield.var,
+            ...xfield,
         });
-    } else if (field.getAttribute('var') === 'password') {
+    } else if (xfield['var'] === 'password') {
         return tplFormInput({
-            'name': field.getAttribute('var'),
-            'type': 'password',
-            'label': field.getAttribute('label') || '',
-            'value': field.querySelector('value')?.textContent,
-            'required': !!field.querySelector('required')
+            name: xfield['var'],
+            type: 'password',
+            ...xfield,
         });
-    } else if (field.getAttribute('var') === 'ocr') {
-        // Captcha
-        const uri = field.querySelector('uri');
-        const el = sizzle('data[cid="' + uri.textContent.replace(/^cid:/, '') + '"]', stanza)[0];
+    } else if (xfield['var'] === 'ocr') {
         return tplFormCaptcha({
-            'label': field.getAttribute('label'),
-            'name': field.getAttribute('var'),
-            'data': el?.textContent,
-            'type': uri.getAttribute('type'),
-            'required': !!field.querySelector('required')
+            name: xfield['var'],
+            data: xfield.uri.data,
+            type: xfield.uri.type,
+            ...xfield,
         });
     } else {
-        const name = field.getAttribute('var');
+        const name = xfield['var'];
         return tplFormInput({
-            'id': u.getUniqueId(),
-            'label': field.getAttribute('label') || '',
-            'name': name,
-            'fixed_username': options?.fixed_username,
-            'autocomplete': getAutoCompleteProperty(name, options),
-            'placeholder': null,
-            'required': !!field.querySelector('required'),
-            'type': getInputType(field),
-            'value': field.querySelector('value')?.textContent
+            name,
+            id: u.getUniqueId(),
+            fixed_username: options?.fixed_username,
+            autocomplete: getAutoCompleteProperty(name, options),
+            placeholder: null,
+            ...xfield,
         });
     }
 }
@@ -615,7 +556,7 @@ Object.assign(u, {
     slideIn,
     slideOut,
     unescapeHTML,
-    xForm2TemplateResult,
+    xFormField2TemplateResult,
 });
 
 export default u;