Ver Fonte

Remove URI.js

Replace usage of URI.js with the built-in URL
Add tests for the shared view utils
Generated type updates. Run prettier.
Run prettier on some files.
JC Brand há 1 mês atrás
pai
commit
fbd0aa2278
40 ficheiros alterados com 945 adições e 384 exclusões
  1. 1 0
      karma.conf.js
  2. 2 9
      package-lock.json
  3. 1 2
      package.json
  4. 1 2
      src/headless/package.json
  5. 0 1
      src/headless/plugins/muc/occupants.js
  6. 0 2
      src/headless/shared/api/public.js
  7. 0 2
      src/headless/shared/constants.js
  8. 1 1
      src/headless/types/plugins/muc/utils.d.ts
  9. 0 3
      src/headless/types/shared/constants.d.ts
  10. 10 9
      src/headless/types/utils/index.d.ts
  11. 2 3
      src/headless/types/utils/types.d.ts
  12. 39 14
      src/headless/types/utils/url.d.ts
  13. 2 4
      src/headless/utils/types.ts
  14. 200 67
      src/headless/utils/url.js
  15. 1 0
      src/index.js
  16. 1 1
      src/plugins/chatview/tests/messages.js
  17. 6 7
      src/plugins/chatview/tests/xss.js
  18. 0 1
      src/plugins/muc-views/tests/mentions.js
  19. 94 76
      src/plugins/omemo/tests/media-sharing.js
  20. 18 21
      src/plugins/omemo/utils.js
  21. 3 13
      src/shared/chat/templates/unfurl.js
  22. 2 2
      src/shared/chat/unfurl.js
  23. 14 16
      src/shared/texture/components/image.js
  24. 16 13
      src/shared/texture/directives/image.js
  25. 12 7
      src/shared/texture/templates/audio.js
  26. 10 7
      src/shared/texture/templates/spotify.js
  27. 7 4
      src/shared/texture/templates/video.js
  28. 9 7
      src/shared/texture/texture.js
  29. 2 18
      src/shared/texture/utils.js
  30. 16 16
      src/templates/hyperlink.js
  31. 1 1
      src/types/shared/texture/directives/image.d.ts
  32. 0 7
      src/types/shared/texture/utils.d.ts
  33. 1 1
      src/types/templates/hyperlink.d.ts
  34. 11 8
      src/types/utils/html.d.ts
  35. 281 0
      src/types/utils/index.d.ts
  36. 17 2
      src/types/utils/url.d.ts
  37. 30 16
      src/utils/html.js
  38. 15 0
      src/utils/index.js
  39. 73 0
      src/utils/tests/url.js
  40. 46 21
      src/utils/url.js

+ 1 - 0
karma.conf.js

@@ -133,6 +133,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/rosterview/tests/presence.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/presence.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/protocol.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/protocol.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/roster.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/roster.js", type: 'module' },
+      { pattern: "src/utils/tests/url.js", type: 'module' },
 
 
       // For some reason this test causes issues when its run earlier
       // For some reason this test causes issues when its run earlier
       { pattern: "src/headless/tests/persistence.js", type: 'module' },
       { pattern: "src/headless/tests/persistence.js", type: 'module' },

+ 2 - 9
package-lock.json

@@ -28,8 +28,7 @@
         "pluggable.js": "^3.0.1",
         "pluggable.js": "^3.0.1",
         "prettier": "^3.2.5",
         "prettier": "^3.2.5",
         "sizzle": "^2.3.5",
         "sizzle": "^2.3.5",
-        "sprintf-js": "^1.1.2",
-        "urijs": "^1.19.10"
+        "sprintf-js": "^1.1.2"
       },
       },
       "devDependencies": {
       "devDependencies": {
         "@babel/cli": "^7.17.10",
         "@babel/cli": "^7.17.10",
@@ -10113,11 +10112,6 @@
         "punycode": "^2.1.0"
         "punycode": "^2.1.0"
       }
       }
     },
     },
-    "node_modules/urijs": {
-      "version": "1.19.11",
-      "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz",
-      "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="
-    },
     "node_modules/url-join": {
     "node_modules/url-join": {
       "version": "4.0.1",
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
       "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
@@ -10824,8 +10818,7 @@
         "pluggable.js": "3.0.1",
         "pluggable.js": "3.0.1",
         "sizzle": "^2.3.5",
         "sizzle": "^2.3.5",
         "sprintf-js": "^1.1.2",
         "sprintf-js": "^1.1.2",
-        "strophe.js": "strophe/strophejs#fb70dcb4e202f632bc9932915b4522f70ad4d47c",
-        "urijs": "^1.19.10"
+        "strophe.js": "strophe/strophejs#fb70dcb4e202f632bc9932915b4522f70ad4d47c"
       },
       },
       "devDependencies": {}
       "devDependencies": {}
     },
     },

+ 1 - 2
package.json

@@ -135,8 +135,7 @@
     "pluggable.js": "^3.0.1",
     "pluggable.js": "^3.0.1",
     "prettier": "^3.2.5",
     "prettier": "^3.2.5",
     "sizzle": "^2.3.5",
     "sizzle": "^2.3.5",
-    "sprintf-js": "^1.1.2",
-    "urijs": "^1.19.10"
+    "sprintf-js": "^1.1.2"
   },
   },
   "resolutions": {
   "resolutions": {
     "autoprefixer": "10.4.5"
     "autoprefixer": "10.4.5"

+ 1 - 2
src/headless/package.json

@@ -43,8 +43,7 @@
     "pluggable.js": "3.0.1",
     "pluggable.js": "3.0.1",
     "sizzle": "^2.3.5",
     "sizzle": "^2.3.5",
     "sprintf-js": "^1.1.2",
     "sprintf-js": "^1.1.2",
-    "strophe.js": "strophe/strophejs#fb70dcb4e202f632bc9932915b4522f70ad4d47c",
-    "urijs": "^1.19.10"
+    "strophe.js": "strophe/strophejs#fb70dcb4e202f632bc9932915b4522f70ad4d47c"
   },
   },
   "devDependencies": {}
   "devDependencies": {}
 }
 }

+ 0 - 1
src/headless/plugins/muc/occupants.js

@@ -6,7 +6,6 @@
  */
  */
 import MUCOccupant from './occupant.js';
 import MUCOccupant from './occupant.js';
 import _converse from '../../shared/_converse.js';
 import _converse from '../../shared/_converse.js';
-import log from "@converse/log";
 import api from '../../shared/api/index.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
 import converse from '../../shared/api/public.js';
 import { Collection, Model } from '@converse/skeletor';
 import { Collection, Model } from '@converse/skeletor';

+ 0 - 2
src/headless/shared/api/public.js

@@ -4,7 +4,6 @@
 import { sprintf } from 'sprintf-js';
 import { sprintf } from 'sprintf-js';
 import dayjs from 'dayjs';
 import dayjs from 'dayjs';
 import sizzle from 'sizzle';
 import sizzle from 'sizzle';
-import URI from 'urijs';
 import { Stanza, Strophe, $build, $iq, $msg, $pres, stx } from 'strophe.js';
 import { Stanza, Strophe, $build, $iq, $msg, $pres, stx } from 'strophe.js';
 import { Collection, Model } from "@converse/skeletor";
 import { Collection, Model } from "@converse/skeletor";
 import { filesize } from 'filesize';
 import { filesize } from 'filesize';
@@ -194,7 +193,6 @@ const converse = Object.assign(/** @type {ConversePrivateGlobal} */(window).conv
         Stanza,
         Stanza,
         Strophe,
         Strophe,
         TimeoutError,
         TimeoutError,
-        URI,
         VERSION_NAME,
         VERSION_NAME,
         dayjs,
         dayjs,
         errors,
         errors,

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

@@ -133,8 +133,6 @@ export const CORE_PLUGINS = [
     'converse-vcard',
     'converse-vcard',
 ];
 ];
 
 
-export const URL_PARSE_OPTIONS = { 'start': /(\b|_)(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi };
-
 export const CHAT_STATES = ['active', 'composing', 'gone', 'inactive', 'paused'];
 export const CHAT_STATES = ['active', 'composing', 'gone', 'inactive', 'paused'];
 
 
 export const KEYCODES = {
 export const KEYCODES = {

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

@@ -11,7 +11,7 @@ export function shouldCreateGroupchatMessage(attrs: any): any;
  * @param {import('./occupant').default} occupant1
  * @param {import('./occupant').default} occupant1
  * @param {import('./occupant').default} occupant2
  * @param {import('./occupant').default} occupant2
  */
  */
-export function occupantsComparator(occupant1: import("./occupant").default, occupant2: import("./occupant").default): 0 | 1 | -1;
+export function occupantsComparator(occupant1: import("./occupant").default, occupant2: import("./occupant").default): 1 | 0 | -1;
 export function registerDirectInvitationHandler(): void;
 export function registerDirectInvitationHandler(): void;
 export function disconnectChatRooms(): any;
 export function disconnectChatRooms(): any;
 export function onWindowStateChanged(): Promise<void>;
 export function onWindowStateChanged(): Promise<void>;

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

@@ -30,9 +30,6 @@ export const HEADLINES_TYPE: "headline";
 export const CONTROLBOX_TYPE: "controlbox";
 export const CONTROLBOX_TYPE: "controlbox";
 export const CONNECTION_STATUS: typeof CONNECTION_STATUS;
 export const CONNECTION_STATUS: typeof CONNECTION_STATUS;
 export const CORE_PLUGINS: string[];
 export const CORE_PLUGINS: string[];
-export namespace URL_PARSE_OPTIONS {
-    let start: RegExp;
-}
 export const CHAT_STATES: string[];
 export const CHAT_STATES: string[];
 export namespace KEYCODES {
 export namespace KEYCODES {
     let TAB: number;
     let TAB: number;

+ 10 - 9
src/headless/types/utils/index.d.ts

@@ -23,18 +23,19 @@ declare const _default: {
     shouldCreateMessage: typeof shouldCreateMessage;
     shouldCreateMessage: typeof shouldCreateMessage;
     triggerEvent: typeof triggerEvent;
     triggerEvent: typeof triggerEvent;
     isValidURL(text: string): boolean;
     isValidURL(text: string): boolean;
-    getURI(url: string | promise.getOpenPromise): any;
-    checkFileTypes(types: string[], url: string): boolean;
-    isURLWithImageExtension(url: any): boolean;
-    isGIFURL(url: any): boolean;
-    isAudioURL(url: any): boolean;
-    isVideoURL(url: any): boolean;
-    isImageURL(url: any): any;
-    isEncryptedFileURL(url: any): any;
+    getURL(url: string | URL): URL;
+    checkFileTypes(types: string[], url: string | URL): boolean;
+    isURLWithImageExtension(url: string | URL): boolean;
+    isGIFURL(url: string | URL): boolean;
+    isAudioURL(url: string | URL): boolean;
+    isVideoURL(url: string | URL): boolean;
+    isImageURL(url: string | URL): boolean;
+    isEncryptedFileURL(url: string | URL): boolean;
     getMediaURLsMetadata(text: string, offset?: number): {
     getMediaURLsMetadata(text: string, offset?: number): {
         media_urls?: import("./types.js").MediaURLMetadata[];
         media_urls?: import("./types.js").MediaURLMetadata[];
     };
     };
-    getMediaURLs(arr: Array<import("./types.js").MediaURLMetadata>, text: string, offset?: number): import("./types.js").MediaURLData[];
+    getMediaURLs(arr: Array<import("./types.js").MediaURLMetadata>, text: string): import("./types.js").MediaURLMetadata[];
+    addMediaURLsOffset(arr: Array<import("./types.js").MediaURLMetadata>, text: string, offset?: number): import("./types.js").MediaURLMetadata[];
     firstCharToUpperCase(text: string): string;
     firstCharToUpperCase(text: string): string;
     getLongestSubstring(string: string, candidates: string[]): string;
     getLongestSubstring(string: string, candidates: string[]): string;
     isString(s: any): boolean;
     isString(s: any): boolean;

+ 2 - 3
src/headless/types/utils/types.d.ts

@@ -17,8 +17,7 @@ export type MediaURLMetadata = {
     is_encrypted?: boolean;
     is_encrypted?: boolean;
     end?: number;
     end?: number;
     start?: number;
     start?: number;
-};
-export type MediaURLData = MediaURLMetadata & {
-    url: string;
+    url: URL;
+    url_text: string;
 };
 };
 //# sourceMappingURL=types.d.ts.map
 //# sourceMappingURL=types.d.ts.map

+ 39 - 14
src/headless/types/utils/url.d.ts

@@ -5,25 +5,46 @@
  */
  */
 export function isValidURL(text: string): boolean;
 export function isValidURL(text: string): boolean;
 /**
 /**
- * @param {string|URI} url
+ * @param {string|URL} url
+ * @returns {URL}
  */
  */
-export function getURI(url: string | URI): any;
+export function getURL(url: string | URL): URL;
 /**
 /**
  * Given the an array of file extensions, check whether a URL points to a file
  * Given the an array of file extensions, check whether a URL points to a file
  * ending in one of them.
  * ending in one of them.
  * @param {string[]} types - An array of file extensions
  * @param {string[]} types - An array of file extensions
- * @param {string} url
+ * @param {string|URL} url
  * @returns {boolean}
  * @returns {boolean}
  * @example
  * @example
  *  checkFileTypes(['.gif'], 'https://conversejs.org/cat.gif?foo=bar');
  *  checkFileTypes(['.gif'], 'https://conversejs.org/cat.gif?foo=bar');
  */
  */
-export function checkFileTypes(types: string[], url: string): boolean;
-export function isURLWithImageExtension(url: any): boolean;
-export function isGIFURL(url: any): boolean;
-export function isAudioURL(url: any): boolean;
-export function isVideoURL(url: any): boolean;
-export function isImageURL(url: any): any;
-export function isEncryptedFileURL(url: any): any;
+export function checkFileTypes(types: string[], url: string | URL): boolean;
+/**
+ * @param {string|URL} url
+ * @returns {boolean}
+ */
+export function isURLWithImageExtension(url: string | URL): boolean;
+/**
+ * @param {string|URL} url
+ */
+export function isGIFURL(url: string | URL): boolean;
+/**
+ * @param {string|URL} url
+ */
+export function isAudioURL(url: string | URL): boolean;
+/**
+ * @param {string|URL} url
+ */
+export function isVideoURL(url: string | URL): boolean;
+/**
+ * @param {string|URL} url
+ * @returns {boolean}
+ */
+export function isImageURL(url: string | URL): boolean;
+/**
+ * @param {string|URL} url
+ */
+export function isEncryptedFileURL(url: string | URL): boolean;
 /**
 /**
  * @param {string} text
  * @param {string} text
  * @param {number} offset
  * @param {number} offset
@@ -33,11 +54,15 @@ export function getMediaURLsMetadata(text: string, offset?: number): {
     media_urls?: import("./types").MediaURLMetadata[];
     media_urls?: import("./types").MediaURLMetadata[];
 };
 };
 /**
 /**
- * Given an array of {@link MediaURLMetadata} objects and text, return an
- * array of {@link MediaURL} objects.
  * @param {Array<import("./types").MediaURLMetadata>} arr
  * @param {Array<import("./types").MediaURLMetadata>} arr
  * @param {string} text
  * @param {string} text
- * @returns {import("./types").MediaURLData[]}
+ * @returns {import("./types").MediaURLMetadata[]}
+ */
+export function getMediaURLs(arr: Array<import("./types").MediaURLMetadata>, text: string): import("./types").MediaURLMetadata[];
+/**
+ * @param {Array<import("./types").MediaURLMetadata>} arr
+ * @param {string} text
+ * @returns {import("./types").MediaURLMetadata[]}
  */
  */
-export function getMediaURLs(arr: Array<import("./types").MediaURLMetadata>, text: string, offset?: number): import("./types").MediaURLData[];
+export function addMediaURLsOffset(arr: Array<import("./types").MediaURLMetadata>, text: string, offset?: number): import("./types").MediaURLMetadata[];
 //# sourceMappingURL=url.d.ts.map
 //# sourceMappingURL=url.d.ts.map

+ 2 - 4
src/headless/utils/types.ts

@@ -19,8 +19,6 @@ export type MediaURLMetadata = {
     is_encrypted?: boolean;
     is_encrypted?: boolean;
     end?: number;
     end?: number;
     start?: number;
     start?: number;
-};
-
-export type MediaURLData = MediaURLMetadata & {
-    url: string;
+    url: URL;
+    url_text: string;
 };
 };

+ 200 - 67
src/headless/utils/url.js

@@ -1,76 +1,177 @@
-import URI from 'urijs';
 import log from "@converse/log";
 import log from "@converse/log";
-import { settings_api } from '../shared/settings/api.js';
-import { URL_PARSE_OPTIONS } from '../shared/constants.js';
+import { settings_api } from "../shared/settings/api.js";
 
 
 const settings = settings_api;
 const settings = settings_api;
 
 
+const URL_REGEXES = {
+    // valid "scheme://" or "www."
+    start: /(\b|_)(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi,
+    // everything up to the next whitespace
+    end: /[\s\r\n]|$/,
+    // trim trailing punctuation captured by end RegExp
+    trim: /[`!()\[\]{};:'".,<>?«»“”„‘’]+$/,
+    // balanced parens inclusion (), [], {}, <>
+    parens: /(\([^\)]*\)|\[[^\]]*\]|\{[^}]*\}|<[^>]*>)/g,
+};
+
 /**
 /**
  * Will return false if URL is malformed or contains disallowed characters
  * Will return false if URL is malformed or contains disallowed characters
  * @param {string} text
  * @param {string} text
  * @returns {boolean}
  * @returns {boolean}
  */
  */
-export function isValidURL (text) {
+export function isValidURL(text) {
     try {
     try {
-        return !!(new URL(text));
+        if (text.startsWith("www.")) {
+            return !!getURL(`http://${text}`);
+        }
+        return !!getURL(text);
     } catch {
     } catch {
         return false;
         return false;
     }
     }
 }
 }
 
 
 /**
 /**
- * @param {string|URI} url
+ * @param {string|URL} url
+ * @returns {URL}
  */
  */
-export function getURI (url) {
-    try {
-        return url instanceof URI ? url : new URI(url);
-    } catch (error) {
-        log.debug(error);
-        return null;
+export function getURL(url) {
+    if (url instanceof URL) {
+        return url;
     }
     }
+    return url.toLowerCase().startsWith("www.") ? getURL(`http://${url}`) : new URL(url);
 }
 }
 
 
 /**
 /**
  * Given the an array of file extensions, check whether a URL points to a file
  * Given the an array of file extensions, check whether a URL points to a file
  * ending in one of them.
  * ending in one of them.
  * @param {string[]} types - An array of file extensions
  * @param {string[]} types - An array of file extensions
- * @param {string} url
+ * @param {string|URL} url
  * @returns {boolean}
  * @returns {boolean}
  * @example
  * @example
  *  checkFileTypes(['.gif'], 'https://conversejs.org/cat.gif?foo=bar');
  *  checkFileTypes(['.gif'], 'https://conversejs.org/cat.gif?foo=bar');
  */
  */
-export function checkFileTypes (types, url) {
-    const uri = getURI(url);
-    if (uri === null) {
+export function checkFileTypes(types, url) {
+    let parsed_url;
+    try {
+        parsed_url = getURL(url);
+    } catch (error) {
         throw new Error(`checkFileTypes: could not parse url ${url}`);
         throw new Error(`checkFileTypes: could not parse url ${url}`);
     }
     }
-    const filename = uri.filename().toLowerCase();
-    return !!types.filter(ext => filename.endsWith(ext)).length;
+    const filename = parsed_url.pathname.split("/").pop().toLowerCase();
+    return !!types.filter((ext) => filename.endsWith(ext)).length;
 }
 }
 
 
-export function isURLWithImageExtension (url) {
-    return checkFileTypes(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.svg'], url);
+/**
+ * @param {string|URL} url
+ * @returns {boolean}
+ */
+export function isURLWithImageExtension(url) {
+    return checkFileTypes([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".svg"], url);
 }
 }
 
 
-export function isGIFURL (url) {
-    return checkFileTypes(['.gif'], url);
+/**
+ * @param {string|URL} url
+ */
+export function isGIFURL(url) {
+    return checkFileTypes([".gif"], url);
 }
 }
 
 
-export function isAudioURL (url) {
-    return checkFileTypes(['.ogg', '.mp3', '.m4a'], url);
+/**
+ * @param {string|URL} url
+ */
+export function isAudioURL(url) {
+    return checkFileTypes([".ogg", ".mp3", ".m4a"], url);
 }
 }
 
 
-export function isVideoURL (url) {
-    return checkFileTypes(['.mp4', '.webm'], url);
+/**
+ * @param {string|URL} url
+ */
+export function isVideoURL(url) {
+    return checkFileTypes([".mp4", ".webm"], url);
 }
 }
 
 
-export function isImageURL (url) {
-    const regex = settings.get('image_urls_regex');
+/**
+ * @param {string|URL} url
+ * @returns {boolean}
+ */
+export function isImageURL(url) {
+    const regex = settings.get("image_urls_regex");
     return regex?.test(url) || isURLWithImageExtension(url);
     return regex?.test(url) || isURLWithImageExtension(url);
 }
 }
 
 
-export function isEncryptedFileURL (url) {
-    return url.startsWith('aesgcm://');
+/**
+ * @param {string|URL} url
+ */
+export function isEncryptedFileURL(url) {
+    return getURL(url).href.startsWith("aesgcm://");
+}
+
+/**
+ * Processes a string to find and manipulate substrings based on a callback function.
+ * This function searches for patterns defined by the provided start and end regular expressions,
+ * and applies the callback to each matched substring, allowing for modifications
+ * @copyright Copyright (c) 2011 Rodney Rehm
+ *
+ * @param {string} string - The input string to be processed.
+ * @param {function} callback - A function that takes the matched substring and its start and end indices,
+ *                              and returns a modified substring or undefined to skip modification.
+ * @param {import("./types").ProcessStringOptions} [options]
+ * @returns {string} The modified string after processing all matches.
+ */
+function withinString(string, callback, options) {
+    options = options || {};
+    const _start = options.start || URL_REGEXES.start;
+    const _end = options.end || URL_REGEXES.end;
+    const _trim = options.trim || URL_REGEXES.trim;
+    const _parens = options.parens || URL_REGEXES.parens;
+    const _attributeOpen = /[a-z0-9-]=["']?$/i;
+
+    _start.lastIndex = 0;
+    while (true) {
+        const match = _start.exec(string);
+        if (!match) break;
+
+        let start = match.index;
+        if (options.ignoreHtml) {
+            const attributeOpen = string.slice(Math.max(start - 3, 0), start);
+            if (attributeOpen && _attributeOpen.test(attributeOpen)) {
+                continue;
+            }
+        }
+
+        let end = start + string.slice(start).search(_end);
+        let slice = string.slice(start, end);
+        let parensEnd = -1;
+        while (true) {
+            const parensMatch = _parens.exec(slice);
+            if (!parensMatch) break;
+
+            const parensMatchEnd = parensMatch.index + parensMatch[0].length;
+            parensEnd = Math.max(parensEnd, parensMatchEnd);
+        }
+
+        if (parensEnd > -1) {
+            slice = slice.slice(0, parensEnd) + slice.slice(parensEnd).replace(_trim, "");
+        } else {
+            slice = slice.replace(_trim, "");
+        }
+
+        if (slice.length <= match[0].length) continue;
+        if (options.ignore && options.ignore.test(slice)) continue;
+
+        end = start + slice.length;
+        const result = callback(slice, start, end);
+        if (result === undefined) {
+            _start.lastIndex = end;
+            continue;
+        }
+
+        string = string.slice(0, start) + String(result) + string.slice(end);
+        _start.lastIndex = start + String(result).length;
+    }
+
+    _start.lastIndex = 0;
+    return string;
 }
 }
 
 
 /**
 /**
@@ -78,63 +179,95 @@ export function isEncryptedFileURL (url) {
  * @param {number} offset
  * @param {number} offset
  * @returns {{media_urls?: import("./types").MediaURLMetadata[]}}
  * @returns {{media_urls?: import("./types").MediaURLMetadata[]}}
  */
  */
-export function getMediaURLsMetadata (text, offset=0) {
+export function getMediaURLsMetadata(text, offset = 0) {
     const objs = [];
     const objs = [];
     if (!text) {
     if (!text) {
         return {};
         return {};
     }
     }
     try {
     try {
-        URI.withinString(
+        withinString(
             text,
             text,
-            (url, start, end) => {
-                if (url.startsWith('_')) {
-                    url = url.slice(1);
+            /**
+             * @param {string} url_text
+             * @param {number} start
+             * @param {number} end
+             * @returns {string|undefined}
+             */
+            (url_text, start, end) => {
+                if (url_text.startsWith("_")) {
+                    url_text = url_text.slice(1);
                     start += 1;
                     start += 1;
                 }
                 }
-                if (url.endsWith('_')) {
-                    url = url.slice(0, url.length-1);
+                if (url_text.endsWith("_")) {
+                    url_text = url_text.slice(0, url_text.length - 1);
                     end -= 1;
                     end -= 1;
                 }
                 }
-                objs.push({ url, 'start': start+offset, 'end': end+offset });
-                return url;
-            },
-            URL_PARSE_OPTIONS
+
+                const url = getURL(url_text);
+                if (url) {
+                    objs.push({ url_text, url, start: start + offset, end: end + offset });
+                }
+                return url_text;
+            }
         );
         );
     } catch (error) {
     } catch (error) {
         log.debug(error);
         log.debug(error);
     }
     }
 
 
-    const media_urls = objs
-        .map(o => ({
-            'end': o.end,
-            'is_audio': isAudioURL(o.url),
-            'is_image': isImageURL(o.url),
-            'is_video': isVideoURL(o.url),
-            'is_encrypted': isEncryptedFileURL(o.url),
-            'start': o.start
-
-        }));
+    const media_urls = objs.map((o) => ({
+        ...o,
+        is_audio: isAudioURL(o.url),
+        is_image: isImageURL(o.url),
+        is_video: isVideoURL(o.url),
+        is_encrypted: isEncryptedFileURL(o.url_text),
+    }));
     return media_urls.length ? { media_urls } : {};
     return media_urls.length ? { media_urls } : {};
 }
 }
 
 
 /**
 /**
- * Given an array of {@link MediaURLMetadata} objects and text, return an
- * array of {@link MediaURL} objects.
  * @param {Array<import("./types").MediaURLMetadata>} arr
  * @param {Array<import("./types").MediaURLMetadata>} arr
  * @param {string} text
  * @param {string} text
- * @returns {import("./types").MediaURLData[]}
+ * @returns {import("./types").MediaURLMetadata[]}
  */
  */
-export function getMediaURLs (arr, text, offset=0) {
-    return arr.map(o => {
-        const start = o.start - offset;
-        const end = o.end - offset;
-        if (start < 0 || start >= text.length) {
-            return null;
-        }
-        return (Object.assign({}, o, {
-            start,
-            end,
-            'url': text.substring(o.start-offset, o.end-offset),
-        }));
-    }).filter(o => o);
+export function getMediaURLs(arr, text) {
+    return arr
+        .map((o) => {
+            if (o.start < 0 || o.start >= text.length) {
+                return null;
+            }
+            const url_text = text.substring(o.start, o.end);
+            let url = null;
+            try {
+                url = getURL(url_text);
+            } catch (e) {
+                log.error(e);
+            }
+            return {
+                ...o,
+                url_text,
+                url,
+            };
+        })
+        .filter((o) => o);
+}
+
+/**
+ * @param {Array<import("./types").MediaURLMetadata>} arr
+ * @param {string} text
+ * @returns {import("./types").MediaURLMetadata[]}
+ */
+export function addMediaURLsOffset(arr, text, offset = 0) {
+    return arr
+        .map((o) => {
+            const start = o.start - offset;
+            const end = o.end - offset;
+            if (start < 0 || start >= text.length) {
+                return null;
+            }
+            return Object.assign({}, o, {
+                start,
+                end,
+            });
+        })
+        .filter((o) => o);
 }
 }

+ 1 - 0
src/index.js

@@ -10,6 +10,7 @@ import "shared/registry.js";
 import { CustomElement } from 'shared/components/element';
 import { CustomElement } from 'shared/components/element';
 import { VIEW_PLUGINS } from './shared/constants.js';
 import { VIEW_PLUGINS } from './shared/constants.js';
 import { _converse, converse } from "@converse/headless";
 import { _converse, converse } from "@converse/headless";
+import "./utils/index.js";
 
 
 /* START: Removable plugins
 /* START: Removable plugins
  * ------------------------
  * ------------------------

+ 1 - 1
src/plugins/chatview/tests/messages.js

@@ -544,7 +544,7 @@ describe("A Chat Message", function () {
         const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
         const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
         expect(msg.textContent).toEqual(message);
         expect(msg.textContent).toEqual(message);
         await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
         await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
-        'This message contains a hyperlink: <a target="_blank" rel="noopener" href="http://www.opkode.com">www.opkode.com</a>');
+        'This message contains a hyperlink: <a target="_blank" rel="noopener" href="http://www.opkode.com/">www.opkode.com</a>');
     }));
     }));
 
 
     it("will remove url query parameters from hyperlinks as set",
     it("will remove url query parameters from hyperlinks as set",

+ 6 - 7
src/plugins/chatview/tests/xss.js

@@ -135,23 +135,22 @@ describe("XSS", function () {
             expect(msg.innerHTML.replace(/<!-.*?->/g, ''))
             expect(msg.innerHTML.replace(/<!-.*?->/g, ''))
                 .toEqual('http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever');
                 .toEqual('http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever');
 
 
-
             await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
             await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
-                `<a target="_blank" rel="noopener" href="http://www.opkode.com/%27onmouseover=%27alert%281%29%27whatever">http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever</a>`);
+               `<a target="_blank" rel="noopener" href="http://www.opkode.com/'onmouseover='alert(1)'whatever">http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever</a>`);
 
 
             message = 'http://www.opkode.com/"onmouseover="alert(1)"whatever';
             message = 'http://www.opkode.com/"onmouseover="alert(1)"whatever';
             await mock.sendMessage(view, message);
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
             expect(msg.textContent).toEqual(message);
             expect(msg.textContent).toEqual(message);
             await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
             await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
-                `<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>`);
+                '<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert(1)%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>');
 
 
             message = "https://en.wikipedia.org/wiki/Ender's_Game";
             message = "https://en.wikipedia.org/wiki/Ender's_Game";
             await mock.sendMessage(view, message);
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
             expect(msg.textContent).toEqual(message);
             expect(msg.textContent).toEqual(message);
             await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
             await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
-                `<a target="_blank" rel="noopener" href="https://en.wikipedia.org/wiki/Ender%27s_Game">https://en.wikipedia.org/wiki/Ender's_Game</a>`);
+                `<a target="_blank" rel="noopener" href="https://en.wikipedia.org/wiki/Ender's_Game">https://en.wikipedia.org/wiki/Ender's_Game</a>`);
 
 
             message = "<https://bugs.documentfoundation.org/show_bug.cgi?id=123737>";
             message = "<https://bugs.documentfoundation.org/show_bug.cgi?id=123737>";
             await mock.sendMessage(view, message);
             await mock.sendMessage(view, message);
@@ -165,14 +164,14 @@ describe("XSS", function () {
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
             expect(msg.textContent).toEqual(message);
             expect(msg.textContent).toEqual(message);
             await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
             await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
-                `&lt;<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>&gt;`);
+                '&lt;<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert(1)%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>&gt;');
 
 
             message = `https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2`
             message = `https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2`
             await mock.sendMessage(view, message);
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
             expect(msg.textContent).toEqual(message);
             expect(msg.textContent).toEqual(message);
             await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
             await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
-                `<a target="_blank" rel="noopener" href="https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=%213m6%211e1%213m4%211sQ7SdHo_bPLPlLlU8GSGWaQ%212e0%217i13312%218i6656%214m5%213m4%211s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08%218m2%213d52.3773668%214d4.5489388%215m1%211e2">https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2</a>`);
+                `<a target="_blank" rel="noopener" href="https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2">https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2</a>`);
         }));
         }));
 
 
         it("will avoid malformed and unsafe urls urls from rendering as anchors",
         it("will avoid malformed and unsafe urls urls from rendering as anchors",
@@ -204,7 +203,7 @@ describe("XSS", function () {
                 href: 'xmpp://anything/?join',
                 href: 'xmpp://anything/?join',
             }, {
             }, {
                 entered: 'WWW.SOMETHING.COM/?x=dKasdDAsd4JAsd3OAJSD23osajAidj',
                 entered: 'WWW.SOMETHING.COM/?x=dKasdDAsd4JAsd3OAJSD23osajAidj',
-                href: 'http://WWW.SOMETHING.COM/?x=dKasdDAsd4JAsd3OAJSD23osajAidj',
+                href: 'http://www.something.com/?x=dKasdDAsd4JAsd3OAJSD23osajAidj',
             }, {
             }, {
                 entered: 'mailto:test@mail.org',
                 entered: 'mailto:test@mail.org',
                 href: 'mailto:test@mail.org',
                 href: 'mailto:test@mail.org',

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

@@ -382,7 +382,6 @@ describe("A sent groupchat message", function () {
         it("can get corrected and given new references",
         it("can get corrected and given new references",
                 mock.initConverse([], {}, async function (_converse) {
                 mock.initConverse([], {}, async function (_converse) {
 
 
-            const nick = 'tom';
             const muc_jid = 'lounge@montague.lit';
             const muc_jid = 'lounge@montague.lit';
             const { api } = _converse;
             const { api } = _converse;
             const { jid: own_jid } = api.connection.get();
             const { jid: own_jid } = api.connection.get();

+ 94 - 76
src/plugins/omemo/tests/media-sharing.js

@@ -1,10 +1,10 @@
 /*global mock, converse */
 /*global mock, converse */
-
-const { $iq, Strophe, u } = converse.env;
-
+const { $iq, Strophe, u, stx } = converse.env;
 
 
 describe("The OMEMO module", function() {
 describe("The OMEMO module", function() {
 
 
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
     it("implements XEP-0454 to encrypt uploaded files",
     it("implements XEP-0454 to encrypt uploaded files",
         mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
         mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
 
 
@@ -23,20 +23,25 @@ describe("The OMEMO module", function() {
         const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
         const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
 
 
         await u.waitUntil(() => mock.initializedOMEMO(_converse));
         await u.waitUntil(() => mock.initializedOMEMO(_converse));
-
         await mock.openChatBoxFor(_converse, contact_jid);
         await mock.openChatBoxFor(_converse, contact_jid);
 
 
         let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
         let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
-        let stanza = $iq({
-                'from': contact_jid,
-                'id': iq_stanza.getAttribute('id'),
-                'to': _converse.api.connection.get().jid,
-                'type': 'result',
-            }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
-                .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
-                    .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
-                        .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
-                            .c('device', {'id': '555'});
+        let stanza = stx`
+            <iq from="${contact_jid}"
+                id="${iq_stanza.getAttribute('id')}"
+                to="${_converse.api.connection.get().jid}"
+                type="result"
+                xmlns="jabber:client">
+            <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                <items node="eu.siacs.conversations.axolotl.devicelist">
+                    <item xmlns="http://jabber.org/protocol/pubsub"> // TODO: must have an id attribute
+                        <list xmlns="eu.siacs.conversations.axolotl">
+                            <device id="555"/>
+                        </list>
+                    </item>
+                </items>
+            </pubsub>
+            </iq>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => _converse.state.omemo_store);
         await u.waitUntil(() => _converse.state.omemo_store);
         const devicelist = _converse.state.devicelists.get({'jid': contact_jid});
         const devicelist = _converse.state.devicelists.get({'jid': contact_jid});
@@ -45,16 +50,17 @@ describe("The OMEMO module", function() {
         const view = _converse.chatboxviews.get(contact_jid);
         const view = _converse.chatboxviews.get(contact_jid);
         const file = new File(['secret'], 'secret.txt', { type: 'text/plain' })
         const file = new File(['secret'], 'secret.txt', { type: 'text/plain' })
         view.model.set('omemo_active', true);
         view.model.set('omemo_active', true);
-        view.model.sendFiles([file]);
+        await view.model.sendFiles([file]);
 
 
         await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
         await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
         const iq = IQ_stanzas.pop();
         const iq = IQ_stanzas.pop();
         const url = base_url+"/secret.txt";
         const url = base_url+"/secret.txt";
-        stanza = u.toStanza(`
+        stanza = stx`
             <iq from="upload.montague.tld"
             <iq from="upload.montague.tld"
                 id="${iq.getAttribute("id")}"
                 id="${iq.getAttribute("id")}"
                 to="romeo@montague.lit/orchard"
                 to="romeo@montague.lit/orchard"
-                type="result">
+                type="result"
+                xmlns="jabber:client">
             <slot xmlns="urn:xmpp:http:upload:0">
             <slot xmlns="urn:xmpp:http:upload:0">
                 <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/secret.txt">
                 <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/secret.txt">
                     <header name="Authorization">Basic Base64String==</header>
                     <header name="Authorization">Basic Base64String==</header>
@@ -62,16 +68,16 @@ describe("The OMEMO module", function() {
                 </put>
                 </put>
                 <get url="${url}" />
                 <get url="${url}" />
             </slot>
             </slot>
-            </iq>`);
+            </iq>`;
 
 
         spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () {
         spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () {
             const message = view.model.messages.at(0);
             const message = view.model.messages.at(0);
             message.set('progress', 1);
             message.set('progress', 1);
             await u.waitUntil(() => view.querySelector('.chat-content progress')?.getAttribute('value') === '1')
             await u.waitUntil(() => view.querySelector('.chat-content progress')?.getAttribute('value') === '1')
             message.save({
             message.save({
-                'upload': _converse.SUCCESS,
-                'oob_url': message.get('get'),
-                'body': message.get('get')
+                upload: _converse.SUCCESS,
+                oob_url: message.get('get'),
+                body: message.get('get')
             });
             });
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         });
         });
@@ -79,42 +85,54 @@ describe("The OMEMO module", function() {
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
 
         iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
         iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
-        stanza = $iq({
-            'from': contact_jid,
-            'id': iq_stanza.getAttribute('id'),
-            'to': _converse.bare_jid,
-            'type': 'result',
-        }).c('pubsub', {
-            'xmlns': 'http://jabber.org/protocol/pubsub'
-            }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:555"})
-                .c('item')
-                    .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
-                        .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up()
-                        .c('signedPreKeySignature').t(btoa('2222')).up()
-                        .c('identityKey').t(btoa('3333')).up()
-                        .c('prekeys')
-                            .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up()
-                            .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up()
-                            .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'));
+        stanza = stx`
+            <iq from="${contact_jid}"
+                id="${iq_stanza.getAttribute('id')}"
+                to="${_converse.bare_jid}"
+                type="result"
+                xmlns="jabber:client">
+            <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                <items node="eu.siacs.conversations.axolotl.bundles:555">
+                    <item>
+                        <bundle xmlns="eu.siacs.conversations.axolotl">
+                            <signedPreKeyPublic signedPreKeyId="4223">${btoa('1111')}</signedPreKeyPublic>
+                            <signedPreKeySignature>${btoa('2222')}</signedPreKeySignature>
+                            <identityKey>${btoa('3333')}</identityKey>
+                            <prekeys>
+                                <preKeyPublic preKeyId="1">${btoa('1001')}</preKeyPublic>
+                                <preKeyPublic preKeyId="2">${btoa('1002')}</preKeyPublic>
+                                <preKeyPublic preKeyId="3">${btoa('1003')}</preKeyPublic>
+                            </prekeys>
+                        </bundle>
+                    </item>
+                </items>
+            </pubsub>
+            </iq>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
         iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
-        stanza = $iq({
-            'from': _converse.bare_jid,
-            'id': iq_stanza.getAttribute('id'),
-            'to': _converse.bare_jid,
-            'type': 'result',
-        }).c('pubsub', {
-            'xmlns': 'http://jabber.org/protocol/pubsub'
-            }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"})
-                .c('item')
-                    .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
-                        .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up()
-                        .c('signedPreKeySignature').t(btoa('200000')).up()
-                        .c('identityKey').t(btoa('300000')).up()
-                        .c('prekeys')
-                            .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up()
-                            .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up()
-                            .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993'));
+        stanza = stx`
+            <iq from="${_converse.bare_jid}"
+                id="${iq_stanza.getAttribute('id')}"
+                to="${_converse.bare_jid}"
+                type="result"
+                xmlns="jabber:client">
+            <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                <items node="eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe">
+                    <item>
+                        <bundle xmlns="eu.siacs.conversations.axolotl">
+                            <signedPreKeyPublic signedPreKeyId="4223">${btoa('100000')}</signedPreKeyPublic>
+                            <signedPreKeySignature>${btoa('200000')}</signedPreKeySignature>
+                            <identityKey>${btoa('300000')}</identityKey>
+                            <prekeys>
+                                <preKeyPublic preKeyId="1">${btoa('1991')}</preKeyPublic>
+                                <preKeyPublic preKeyId="2">${btoa('1992')}</preKeyPublic>
+                                <preKeyPublic preKeyId="3">${btoa('1993')}</preKeyPublic>
+                            </prekeys>
+                        </bundle>
+                    </item>
+                </items>
+            </pubsub>
+            </iq>`;
 
 
         spyOn(_converse.api.connection.get(), 'send').and.callFake(stanza => (sent_stanza = stanza));
         spyOn(_converse.api.connection.get(), 'send').and.callFake(stanza => (sent_stanza = stanza));
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
@@ -122,27 +140,27 @@ describe("The OMEMO module", function() {
         await u.waitUntil(() => sent_stanza);
         await u.waitUntil(() => sent_stanza);
 
 
         const fallback = 'This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo';
         const fallback = 'This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo';
-        expect(Strophe.serialize(sent_stanza)).toBe(
-            `<message from="romeo@montague.lit/orchard" `+
-                `id="${sent_stanza.getAttribute("id")}" `+
-                `to="lady.montague@montague.lit" `+
-                `type="chat" `+
-                `xmlns="jabber:client">`+
-                    `<body>${fallback}</body>`+
-                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                    `<request xmlns="urn:xmpp:receipts"/>`+
-                    `<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
-                    `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
-                    `<header sid="123456789">`+
-                        `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
-                        `<key rid="555">YzFwaDNSNzNYNw==</key>`+
-                        `<iv>${sent_stanza.querySelector('header iv').textContent}</iv>`+
-                    `</header>`+
-                `<payload>${sent_stanza.querySelector('payload').textContent}</payload>`+
-                `</encrypted>`+
-                `<store xmlns="urn:xmpp:hints"/>`+
-                `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+
-            `</message>`);
+        expect(sent_stanza).toEqualStanza(stx`
+            <message from="romeo@montague.lit/orchard"
+                id="${sent_stanza.getAttribute("id")}"
+                to="lady.montague@montague.lit"
+                type="chat"
+                xmlns="jabber:client">
+                    <body>${fallback}</body>
+                    <active xmlns="http://jabber.org/protocol/chatstates"/>
+                    <request xmlns="urn:xmpp:receipts"/>
+                    <origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>
+                    <encrypted xmlns="eu.siacs.conversations.axolotl">
+                    <header sid="123456789">
+                        <key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>
+                        <key rid="555">YzFwaDNSNzNYNw==</key>
+                        <iv>${sent_stanza.querySelector('header iv').textContent}</iv>
+                    </header>
+                <payload>${sent_stanza.querySelector('payload').textContent}</payload>
+                </encrypted>
+                <store xmlns="urn:xmpp:hints"/>
+                <encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>
+            </message>`);
 
 
         const link_el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         const link_el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(link_el.textContent.trim()).toBe(url);
         expect(link_el.textContent.trim()).toBe(url);

+ 18 - 21
src/plugins/omemo/utils.js

@@ -18,7 +18,7 @@ import { MIMETYPES_MAP } from "utils/file.js";
 import { IQError, UserFacingError } from "shared/errors.js";
 import { IQError, UserFacingError } from "shared/errors.js";
 import DeviceLists from "./devicelists.js";
 import DeviceLists from "./devicelists.js";
 
 
-const { Strophe, URI, sizzle, stx } = converse.env;
+const { Strophe, sizzle, stx } = converse.env;
 const { CHATROOMS_TYPE, PRIVATE_CHAT_TYPE } = constants;
 const { CHATROOMS_TYPE, PRIVATE_CHAT_TYPE } = constants;
 const {
 const {
     appendArrayBuffer,
     appendArrayBuffer,
@@ -26,7 +26,7 @@ const {
     arrayBufferToHex,
     arrayBufferToHex,
     arrayBufferToString,
     arrayBufferToString,
     base64ToArrayBuffer,
     base64ToArrayBuffer,
-    getURI,
+    getURL,
     hexToArrayBuffer,
     hexToArrayBuffer,
     initStorage,
     initStorage,
     isAudioURL,
     isAudioURL,
@@ -286,6 +286,7 @@ function getTemplateForObjectURL(uri, obj_url, richtext) {
     }
     }
 }
 }
 
 
+
 /**
 /**
  * @param {string} text
  * @param {string} text
  * @param {number} offset
  * @param {number} offset
@@ -293,27 +294,23 @@ function getTemplateForObjectURL(uri, obj_url, richtext) {
  */
  */
 function addEncryptedFiles(text, offset, richtext) {
 function addEncryptedFiles(text, offset, richtext) {
     const objs = [];
     const objs = [];
-    try {
-        const parse_options = { "start": /\b(aesgcm:\/\/)/gi };
-        URI.withinString(
-            text,
-            /**
-             * @param {string} url
-             * @param {number} start
-             * @param {number} end
-             */
-            (url, start, end) => {
-                objs.push({ url, start, end });
-                return url;
-            },
-            parse_options
-        );
-    } catch (error) {
-        log.debug(error);
-        return;
+    const regex = /\b(aesgcm:\/\/[^\s\r\n]+)/gi;
+    const trailing_punctuation = /[`!()\[\]{};:'".,<>?«»“”„‘’]+$/;
+    const balanced_parens = /(\([^\)]*\)|\[[^\]]*\]|\{[^}]*\}|<[^>]*>)/g;
+
+    let match;
+    while ((match = regex.exec(text)) !== null) {
+        const url = match[0].replace(trailing_punctuation, "");
+        const start = match.index;
+        const end = start + url.length;
+        // Check for balanced parentheses
+        if (balanced_parens.test(url)) {
+            objs.push({ url, start, end });
+        }
     }
     }
+
     objs.forEach((o) => {
     objs.forEach((o) => {
-        const uri = getURI(text.slice(o.start, o.end));
+        const uri = getURL(o.url);
         const promise = getAndDecryptFile(uri).then((obj_url) => getTemplateForObjectURL(uri, obj_url, richtext));
         const promise = getAndDecryptFile(uri).then((obj_url) => getTemplateForObjectURL(uri, obj_url, richtext));
 
 
         const template = html`${until(promise, "")}`;
         const template = html`${until(promise, "")}`;

+ 3 - 13
src/shared/chat/templates/unfurl.js

@@ -3,22 +3,12 @@ import { isDomainAllowed } from 'utils/url.js';
 import { html } from 'lit';
 import { html } from 'lit';
 import 'shared/texture/components/image.js';
 import 'shared/texture/components/image.js';
 
 
-const { getURI, isGIFURL } = u;
-
-/**
- * @param {string} url
- */
-function isValidURL (url) {
-    // We don't consider relative URLs as valid
-    return !!getURI(url).host();
-}
-
 function isValidImage (image) {
 function isValidImage (image) {
-    return image && isDomainAllowed(image, 'allowed_image_domains') && isValidURL(image);
+    return image && isDomainAllowed(image, 'allowed_image_domains') && u.isValidURL(image);
 }
 }
 
 
 const tplUrlWrapper = (o, wrapped_template) =>
 const tplUrlWrapper = (o, wrapped_template) =>
-    o.url && isValidURL(o.url) && !isGIFURL(o.image)
+    o.url && u.isValidURL(o.url) && !u.isGIFURL(o.image)
         ? html`<a href="${o.url}" target="_blank" rel="noopener">${wrapped_template(o)}</a>`
         ? html`<a href="${o.url}" target="_blank" rel="noopener">${wrapped_template(o)}</a>`
         : wrapped_template(o);
         : wrapped_template(o);
 
 
@@ -41,7 +31,7 @@ export default (o) => {
                           : ''}
                           : ''}
                       ${o.url
                       ${o.url
                           ? html`<p class="card-text">
                           ? html`<p class="card-text">
-                                <a href="${o.url}" target="_blank" rel="noopener">${getURI(o.url).domain()}</a>
+                                <a href="${o.url}" target="_blank" rel="noopener">${new URL(o.url).hostname}</a>
                             </p>`
                             </p>`
                           : ''}
                           : ''}
                   </div>`
                   </div>`

+ 2 - 2
src/shared/chat/unfurl.js

@@ -1,6 +1,6 @@
-import tplUnfurl from './templates/unfurl.js';
-import { CustomElement } from 'shared/components/element.js';
 import { api } from "@converse/headless";
 import { api } from "@converse/headless";
+import { CustomElement } from 'shared/components/element.js';
+import tplUnfurl from './templates/unfurl.js';
 
 
 import './styles/unfurl.scss';
 import './styles/unfurl.scss';
 
 

+ 14 - 16
src/shared/texture/components/image.js

@@ -1,24 +1,22 @@
 import { api, u } from "@converse/headless";
 import { api, u } from "@converse/headless";
 import { CustomElement } from "shared/components/element";
 import { CustomElement } from "shared/components/element";
-import tplGif from 'shared/texture/templates/gif.js';
-import tplImage from '../templates/image.js';
-import { shouldRenderMediaFromURL } from 'utils/url.js';
-
-const { filterQueryParamsFromURL, isGIFURL } = u;
+import tplGif from "shared/texture/templates/gif.js";
+import { shouldRenderMediaFromURL, filterQueryParamsFromURL  } from "utils/url.js";
+import tplImage from "../templates/image.js";
 
 
+const { isGIFURL } = u;
 
 
 export default class Image extends CustomElement {
 export default class Image extends CustomElement {
-
-    static get properties () {
+    static get properties() {
         return {
         return {
             src: { type: String },
             src: { type: String },
             onImgLoad: { type: Function },
             onImgLoad: { type: Function },
             // If specified, image is wrapped in a hyperlink that points to this URL.
             // If specified, image is wrapped in a hyperlink that points to this URL.
             href: { type: String },
             href: { type: String },
-        }
+        };
     }
     }
 
 
-    constructor () {
+    constructor() {
         super();
         super();
         this.src = null;
         this.src = null;
         this.href = null;
         this.href = null;
@@ -26,18 +24,18 @@ export default class Image extends CustomElement {
         this.onImgLoad = null;
         this.onImgLoad = null;
     }
     }
 
 
-    render () {
-        if (isGIFURL(this.src) && shouldRenderMediaFromURL(this.src, 'image')) {
+    render() {
+        if (isGIFURL(this.src) && shouldRenderMediaFromURL(this.src, "image")) {
             return tplGif(filterQueryParamsFromURL(this.src), true);
             return tplGif(filterQueryParamsFromURL(this.src), true);
         } else {
         } else {
             return tplImage({
             return tplImage({
-                'src': filterQueryParamsFromURL(this.src),
-                'href': this.href,
-                'onClick': this.onImgClick,
-                'onLoad': this.onImgLoad
+                "src": filterQueryParamsFromURL(this.src),
+                "href": this.href,
+                "onClick": this.onImgClick,
+                "onLoad": this.onImgLoad,
             });
             });
         }
         }
     }
     }
 }
 }
 
 
-api.elements.define('converse-image', Image);
+api.elements.define("converse-image", Image);

+ 16 - 13
src/shared/texture/directives/image.js

@@ -1,10 +1,9 @@
-import { html } from 'lit';
-import { AsyncDirective } from 'lit/async-directive.js';
-import { directive } from 'lit/directive.js';
-import { converse, u } from '@converse/headless';
-import { getHyperlinkTemplate } from 'utils/html.js';
+import { html } from "lit";
+import { AsyncDirective } from "lit/async-directive.js";
+import { directive } from "lit/directive.js";
+import { u } from "@converse/headless";
+import { getHyperlinkTemplate } from "utils/html.js";
 
 
-const { URI } = converse.env;
 const { isURLWithImageExtension } = u;
 const { isURLWithImageExtension } = u;
 
 
 class ImageDirective extends AsyncDirective {
 class ImageDirective extends AsyncDirective {
@@ -50,13 +49,17 @@ class ImageDirective extends AsyncDirective {
         if (isURLWithImageExtension(src)) {
         if (isURLWithImageExtension(src)) {
             href && this.setValue(getHyperlinkTemplate(href));
             href && this.setValue(getHyperlinkTemplate(href));
         } else {
         } else {
-            // Before giving up and falling back to just rendering a hyperlink,
-            // we attach `.png` and try one more time.
-            // This works with some Imgur URLs
-            const uri = new URI(src);
-            const filename = uri.filename();
-            uri.filename(`${filename}.png`);
-            this.setValue(renderImage(uri.toString(), href, onLoad, onClick));
+            try {
+                const url = new URL(src);
+                const filename = url.pathname.split("/").pop();
+                if (filename) {
+                    const new_filename = `${filename}.png`;
+                    url.pathname = url.pathname.replace(filename, new_filename);
+                    this.setValue(renderImage(url.toString(), href, onLoad, onClick));
+                }
+            } catch (error) {
+                console.error("Invalid URL:", src);
+            }
         }
         }
     }
     }
 }
 }

+ 12 - 7
src/shared/texture/templates/audio.js

@@ -1,6 +1,7 @@
-import { html } from 'lit';
+import { html } from "lit";
+import { u } from "@converse/headless";
 
 
-import '../styles/audio.scss';
+import "../styles/audio.scss";
 
 
 /**
 /**
  * @param {string} url
  * @param {string} url
@@ -8,12 +9,16 @@ import '../styles/audio.scss';
  * @param {string} [title]
  * @param {string} [title]
  */
  */
 export default (url, hide_url, title) => {
 export default (url, hide_url, title) => {
-    const { hostname } = new URL(url);
+    const { hostname } = u.getURL(url);
     return html`<figure class="audio-element">
     return html`<figure class="audio-element">
-        ${title || !hide_url ? html`<figcaption>
-            ${title ? html`${title}</br>` : ''}
-            ${hide_url ? '' : html`<a target="_blank" rel="noopener" title="${url}" href="${url}">${hostname}</a>`}
-        </figcaption>` : ''}
+        ${title || !hide_url
+            ? html`<figcaption>
+                  ${title ? html`${title}</br>` : ""}
+                  ${hide_url
+                      ? ""
+                      : html`<a target="_blank" rel="noopener" title="${url}" href="${url}">${hostname}</a>`}
+              </figcaption>`
+            : ""}
         <audio controls src="${url}"></audio>
         <audio controls src="${url}"></audio>
     </figure>`;
     </figure>`;
 };
 };

+ 10 - 7
src/shared/texture/templates/spotify.js

@@ -1,4 +1,5 @@
-import { html } from 'lit';
+import { html } from "lit";
+import { u } from "@converse/headless";
 
 
 /**
 /**
  * @param {string} song_id - The ID of the song to embed.
  * @param {string} song_id - The ID of the song to embed.
@@ -7,16 +8,18 @@ import { html } from 'lit';
  * @returns {import('lit').TemplateResult}
  * @returns {import('lit').TemplateResult}
  */
  */
 export default (song_id, url, hide_url) => {
 export default (song_id, url, hide_url) => {
-    const { hostname } = new URL(url);
+    const { hostname } = u.getURL(url);
     return html`<figure>
     return html`<figure>
         <iframe
         <iframe
             style="border-radius:12px"
             style="border-radius:12px"
             src="https://open.spotify.com/embed/track/${song_id}"
             src="https://open.spotify.com/embed/track/${song_id}"
             width="100%"
             width="100%"
             height="352"
             height="352"
-            frameBorder="0"
+            frameborder="0"
             allowfullscreen=""
             allowfullscreen=""
-            allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>
-            ${hide_url ? '' : html`<a target="_blank" rel="noopener" title="${url}" href="${url}">${hostname}</a>`}
-        </figure>`;
-}
+            allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
+            loading="lazy"
+        ></iframe>
+        ${hide_url ? "" : html`<a target="_blank" rel="noopener" title="${url}" href="${url}">${hostname}</a>`}
+    </figure>`;
+};

+ 7 - 4
src/shared/texture/templates/video.js

@@ -1,13 +1,16 @@
-import { html } from 'lit';
+import { html } from "lit";
+import { u } from "@converse/headless";
 
 
 /**
 /**
  * @param {string} url
  * @param {string} url
  * @param {boolean} [hide_url]
  * @param {boolean} [hide_url]
  */
  */
 export default (url, hide_url) => {
 export default (url, hide_url) => {
-    const { hostname } = new URL(url);
+    const { hostname } = u.getURL(url);
     return html`<figure>
     return html`<figure>
         <video controls preload="metadata" src="${url}"></video>
         <video controls preload="metadata" src="${url}"></video>
-        ${hide_url || !hostname ? '' : html`<a target="_blank" rel="noopener" title="${url}" href="${url}">${hostname}</a>`}
+        ${hide_url || !hostname
+            ? ""
+            : html`<a target="_blank" rel="noopener" title="${url}" href="${url}">${hostname}</a>`}
     </figure>`;
     </figure>`;
-}
+};

+ 9 - 7
src/shared/texture/texture.js

@@ -9,11 +9,10 @@ import tplVideo from "./templates/video.js";
 import tplSpotify from "./templates/spotify.js";
 import tplSpotify from "./templates/spotify.js";
 import { getEmojiMarkup } from "../chat/utils.js";
 import { getEmojiMarkup } from "../chat/utils.js";
 import { getHyperlinkTemplate } from "../../utils/html.js";
 import { getHyperlinkTemplate } from "../../utils/html.js";
-import { shouldRenderMediaFromURL } from "utils/url.js";
+import { shouldRenderMediaFromURL, filterQueryParamsFromURL } from "utils/url.js";
 import {
 import {
     collapseLineBreaks,
     collapseLineBreaks,
     containsDirectives,
     containsDirectives,
-    filterQueryParamsFromURL,
     getDirectiveAndLength,
     getDirectiveAndLength,
     getHeaders,
     getHeaders,
     isQuoteDirective,
     isQuoteDirective,
@@ -25,9 +24,9 @@ import {
 import { styling_map } from "./constants.js";
 import { styling_map } from "./constants.js";
 
 
 const {
 const {
+    addMediaURLsOffset,
     convertASCII2Emoji,
     convertASCII2Emoji,
     getCodePointReferences,
     getCodePointReferences,
-    getMediaURLs,
     getMediaURLsMetadata,
     getMediaURLsMetadata,
     getShortnameReferences,
     getShortnameReferences,
     isAudioURL,
     isAudioURL,
@@ -130,7 +129,7 @@ export class Texture extends String {
      * @returns {Promise<string|import('lit').TemplateResult>}
      * @returns {Promise<string|import('lit').TemplateResult>}
      */
      */
     async addHyperlinkTemplate(url_obj) {
     async addHyperlinkTemplate(url_obj) {
-        const url_text = url_obj.url;
+        const { url_text } = url_obj;
         const filtered_url = filterQueryParamsFromURL(url_text);
         const filtered_url = filterQueryParamsFromURL(url_text);
         let template;
         let template;
         if (isGIFURL(url_text) && this.shouldRenderMedia(url_text, "image")) {
         if (isGIFURL(url_text) && this.shouldRenderMedia(url_text, "image")) {
@@ -169,9 +168,11 @@ export class Texture extends String {
      *  offset from the start of the original message stanza's body text).
      *  offset from the start of the original message stanza's body text).
      */
      */
     async addHyperlinks(text, local_offset) {
     async addHyperlinks(text, local_offset) {
-        const full_offset = local_offset + this.offset;
-        const urls_meta = this.media_urls || getMediaURLsMetadata(text, local_offset).media_urls || [];
-        const media_urls = getMediaURLs(urls_meta, text, full_offset);
+        const media_urls = addMediaURLsOffset(
+            getMediaURLsMetadata(text, local_offset).media_urls || [],
+            text,
+            local_offset
+        );
         await Promise.all(
         await Promise.all(
             media_urls
             media_urls
                 .filter((o) => !o.is_encrypted)
                 .filter((o) => !o.is_encrypted)
@@ -332,6 +333,7 @@ export class Texture extends String {
         await api.trigger("beforeMessageBodyTransformed", this, { synchronous: true });
         await api.trigger("beforeMessageBodyTransformed", this, { synchronous: true });
 
 
         this.render_styling && this.addStyling();
         this.render_styling && this.addStyling();
+
         await this.addAnnotations(this.addMentions);
         await this.addAnnotations(this.addMentions);
         await this.addAnnotations(this.addHyperlinks);
         await this.addAnnotations(this.addHyperlinks);
         await this.addAnnotations(this.addMapURLs);
         await this.addAnnotations(this.addMapURLs);

+ 2 - 18
src/shared/texture/utils.js

@@ -1,5 +1,5 @@
 import { html } from "lit";
 import { html } from "lit";
-import { api } from "@converse/headless";
+import { u } from "@converse/headless";
 import { bracketing_directives, dont_escape, styling_directives, styling_map } from "./constants";
 import { bracketing_directives, dont_escape, styling_directives, styling_map } from "./constants";
 
 
 /**
 /**
@@ -16,7 +16,7 @@ export function isString(s) {
  */
  */
 export function isSpotifyTrack(url) {
 export function isSpotifyTrack(url) {
     try {
     try {
-        const { hostname, pathname } = new URL(url);
+        const { hostname, pathname } = u.getURL(url);
         return hostname === "open.spotify.com" && pathname.startsWith("/track/");
         return hostname === "open.spotify.com" && pathname.startsWith("/track/");
     } catch (e) {
     } catch (e) {
         console.debug(`Could not create URL object from ${url}`);
         console.debug(`Could not create URL object from ${url}`);
@@ -193,19 +193,3 @@ export function containsDirectives(text) {
     return false;
     return false;
 }
 }
 
 
-/**
- * Takes the `filter_url_query_params` array from the settings and
- * removes any query strings from the URL that matches those values.
- * @param {string} url
- * @return {string}
- */
-export function filterQueryParamsFromURL(url) {
-    const setting = api.settings.get("filter_url_query_params");
-    if (!setting) return url;
-
-    const to_remove = Array.isArray(setting) ? setting : [setting];
-    const url_obj = new URL(url);
-    to_remove.forEach(/** @param {string} p */(p) => url_obj.searchParams.delete(p));
-
-    return url_obj.toString();
-}

+ 16 - 16
src/templates/hyperlink.js

@@ -1,22 +1,22 @@
-import { api } from  "@converse/headless";
+import { api } from "@converse/headless";
 import { html } from "lit";
 import { html } from "lit";
 
 
-function onClickXMPPURI (ev) {
+/**
+ * @param {MouseEvent} ev
+ */
+function onClickXMPPURI(ev) {
     ev.preventDefault();
     ev.preventDefault();
-    api.rooms.open(ev.target.href);
+    api.rooms.open(/** @type {HTMLAnchorElement} */ (ev.target).href);
 }
 }
 
 
-export default (uri, url_text) => {
-    let href_text = uri.normalizePath().toString();
-    if (!uri._parts.protocol && !url_text.startsWith('http://') && !url_text.startsWith('https://')) {
-        href_text = 'http://' + href_text;
+/**
+ * @param {URL} url - The url object containing the link information.
+ * @param {string} url_text - The text to display for the link.
+ * @returns {import("lit").TemplateResult} The HTML template for the link.
+ */
+export default (url, url_text) => {
+    if (url.protocol === "xmpp:" && url.searchParams.get("join") != null) { // eslint-disable-line no-eq-null
+        return html` <a target="_blank" rel="noopener" @click="${onClickXMPPURI}" href="${url.href}">${url_text}</a>`;
     }
     }
-    if (uri._parts.protocol === 'xmpp' && uri._parts.query === 'join') {
-        return html`
-            <a target="_blank"
-               rel="noopener"
-               @click=${onClickXMPPURI}
-               href="${href_text}">${url_text}</a>`;
-    }
-    return html`<a target="_blank" rel="noopener" href="${href_text}">${url_text}</a>`;
-}
+    return html`<a target="_blank" rel="noopener" href="${url.href}">${url_text}</a>`;
+};

+ 1 - 1
src/types/shared/texture/directives/image.d.ts

@@ -34,6 +34,6 @@ declare class ImageDirective extends AsyncDirective {
      */
      */
     onError(src: string, href?: string, onLoad?: Function, onClick?: Function): void;
     onError(src: string, href?: string, onLoad?: Function, onClick?: Function): void;
 }
 }
-import { AsyncDirective } from 'lit/async-directive.js';
+import { AsyncDirective } from "lit/async-directive.js";
 export {};
 export {};
 //# sourceMappingURL=image.d.ts.map
 //# sourceMappingURL=image.d.ts.map

+ 0 - 7
src/types/shared/texture/utils.d.ts

@@ -43,12 +43,5 @@ export function isQuoteDirective(d: string): boolean;
  * @returns {boolean}
  * @returns {boolean}
  */
  */
 export function containsDirectives(text: import("./texture").Texture): boolean;
 export function containsDirectives(text: import("./texture").Texture): boolean;
-/**
- * Takes the `filter_url_query_params` array from the settings and
- * removes any query strings from the URL that matches those values.
- * @param {string} url
- * @return {string}
- */
-export function filterQueryParamsFromURL(url: string): string;
 export function tplMentionWithNick(o: any): import("lit").TemplateResult<1>;
 export function tplMentionWithNick(o: any): import("lit").TemplateResult<1>;
 //# sourceMappingURL=utils.d.ts.map
 //# sourceMappingURL=utils.d.ts.map

+ 1 - 1
src/types/templates/hyperlink.d.ts

@@ -1,3 +1,3 @@
-declare function _default(uri: any, url_text: any): import("lit").TemplateResult<1>;
+declare function _default(url: URL, url_text: string): import("lit").TemplateResult;
 export default _default;
 export default _default;
 //# sourceMappingURL=hyperlink.d.ts.map
 //# sourceMappingURL=hyperlink.d.ts.map

+ 11 - 8
src/types/utils/html.d.ts

@@ -6,28 +6,31 @@
 export function getNameAndValue(field: HTMLInputElement | HTMLSelectElement): {
 export function getNameAndValue(field: HTMLInputElement | HTMLSelectElement): {
     [key: string]: string | number | string[];
     [key: string]: string | number | string[];
 } | null;
 } | null;
-export function getFileName(url: any): any;
+/**
+ * @param {string} url
+ */
+export function getFileName(url: string): string;
 /**
 /**
  * Has an element a class?
  * Has an element a class?
- * @param { string } className
- * @param { Element } el
+ * @param {string} className
+ * @param {Element} el
  */
  */
 export function hasClass(className: string, el: Element): boolean;
 export function hasClass(className: string, el: Element): boolean;
 /**
 /**
  * Add a class to an element.
  * Add a class to an element.
- * @param { string } className
- * @param { Element } el
+ * @param {string} className
+ * @param {Element} el
  */
  */
 export function addClass(className: string, el: Element): Element;
 export function addClass(className: string, el: Element): Element;
 /**
 /**
  * Remove a class from an element.
  * Remove a class from an element.
- * @param { string } className
- * @param { Element } el
+ * @param {string} className
+ * @param {Element} el
  */
  */
 export function removeClass(className: string, el: Element): Element;
 export function removeClass(className: string, el: Element): Element;
 /**
 /**
  * Remove an element from its parent
  * Remove an element from its parent
- * @param { Element } el
+ * @param {Element} el
  */
  */
 export function removeElement(el: Element): Element;
 export function removeElement(el: Element): Element;
 /**
 /**

+ 281 - 0
src/types/utils/index.d.ts

@@ -0,0 +1,281 @@
+declare const _default: {
+    getRandomInt: typeof import("headless/types/utils/index.js").getRandomInt;
+    getUniqueId: typeof import("headless/types/utils/index.js").getUniqueId;
+    isEmptyMessage: typeof import("headless/types/utils/index.js").isEmptyMessage;
+    onMultipleEvents: (events: any[], callback: Function) => void;
+    prefixMentions: typeof import("headless/types/utils/index.js").prefixMentions;
+    shouldCreateMessage: (attrs: any) => any;
+    triggerEvent: (el: Element, name: string, type?: string, bubbles?: boolean, cancelable?: boolean) => void;
+    isValidURL(text: string): boolean;
+    getURL(url: string | URL): URL;
+    checkFileTypes(types: string[], url: string | URL): boolean;
+    isURLWithImageExtension(url: string | URL): boolean;
+    isGIFURL(url: string | URL): boolean;
+    isAudioURL(url: string | URL): boolean;
+    isVideoURL(url: string | URL): boolean;
+    isImageURL(url: string | URL): boolean;
+    isEncryptedFileURL(url: string | URL): boolean;
+    getMediaURLsMetadata(text: string, offset?: number): {
+        media_urls?: import("headless/types/utils/types.js").MediaURLMetadata[];
+    };
+    getMediaURLs(arr: Array<import("headless/types/utils/types.js").MediaURLMetadata>, text: string): import("headless/types/utils/types.js").MediaURLMetadata[];
+    addMediaURLsOffset(arr: Array<import("headless/types/utils/types.js").MediaURLMetadata>, text: string, offset?: number): import("headless/types/utils/types.js").MediaURLMetadata[];
+    firstCharToUpperCase(text: string): string;
+    getLongestSubstring(string: string, candidates: string[]): string;
+    isString(s: any): boolean;
+    getDefaultStore(): "session" | "persistent";
+    createStore(id: any, store: any): any;
+    initStorage(model: any, id: any, type: any): void;
+    isErrorStanza(stanza: Element): boolean;
+    isForbiddenError(stanza: Element): boolean;
+    isServiceUnavailableError(stanza: Element): boolean;
+    getAttributes(stanza: Element): object;
+    toStanza: typeof import("strophe.js").Stanza.toElement;
+    isUniView(): boolean;
+    isTestEnv(): boolean;
+    getUnloadEvent(): "pagehide" | "beforeunload" | "unload";
+    replacePromise(_converse: ConversePrivateGlobal, name: string): void;
+    shouldClearCache(_converse: ConversePrivateGlobal): boolean;
+    tearDown(_converse: ConversePrivateGlobal): Promise<any>;
+    clearSession(_converse: ConversePrivateGlobal): any;
+    waitUntil(func: Function, max_wait?: number, check_delay?: number): Promise<any>;
+    getOpenPromise: any;
+    merge(dst: any, src: any): void;
+    isError(obj: unknown): boolean;
+    isFunction(val: unknown): boolean;
+    isUndefined(x: unknown): boolean;
+    isErrorObject(o: unknown): boolean;
+    isPersistableModel(model: import("@converse/skeletor").Model): boolean;
+    isValidJID(jid?: string | null): boolean;
+    isValidMUCJID(jid: string): boolean;
+    isSameBareJID(jid1: string, jid2: string): boolean;
+    isSameDomain(jid1: string, jid2: string): boolean;
+    getJIDFromURI(jid: string): string;
+    initPlugins(_converse: ConversePrivateGlobal): void;
+    initClientConfig(_converse: ConversePrivateGlobal): Promise<void>;
+    initSessionStorage(_converse: ConversePrivateGlobal): Promise<void>;
+    initPersistentStorage(_converse: ConversePrivateGlobal, store_name: string, key?: string): void;
+    setUserJID(jid: string): Promise<string>;
+    initSession(_converse: ConversePrivateGlobal, jid: string): Promise<void>;
+    registerGlobalEventHandlers(_converse: ConversePrivateGlobal): void;
+    cleanup(_converse: ConversePrivateGlobal): Promise<void>;
+    attemptNonPreboundSession(credentials?: import("headless/types/utils/types.js").Credentials, automatic?: boolean): Promise<void>;
+    savedLoginInfo(jid: string): Promise<import("@converse/skeletor").Model>;
+    safeSave(model: import("@converse/skeletor").Model, attributes: any, options: any): void;
+    isElement(el: unknown): boolean;
+    isTagEqual(stanza: Element | typeof import("strophe.js").Builder, name: string): boolean;
+    stringToElement(s: string): Element;
+    queryChildren(el: HTMLElement, selector: string): ChildNode[];
+    siblingIndex(el: Element): number;
+    decodeHTMLEntities(str: string): string;
+    getSelectValues(select: HTMLSelectElement): string[];
+    webForm2xForm(field: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement): Element;
+    getCurrentWord(input: HTMLInputElement | HTMLTextAreaElement, index?: number, delineator?: string | RegExp): string;
+    isMentionBoundary(s: string): boolean;
+    replaceCurrentWord(input: HTMLInputElement, new_value: string): void;
+    placeCaretAtEnd(textarea: HTMLTextAreaElement): void;
+    colorize(s: string): Promise<string>;
+    appendArrayBuffer(buffer1: any, buffer2: any): ArrayBufferLike;
+    arrayBufferToHex(ab: any): any;
+    arrayBufferToString(ab: any): string;
+    stringToArrayBuffer(string: any): ArrayBufferLike;
+    arrayBufferToBase64(ab: any): string;
+    base64ToArrayBuffer(b64: any): ArrayBufferLike;
+    hexToArrayBuffer(hex: any): ArrayBufferLike;
+    unique<T extends unknown>(arr: Array<T>): Array<T>;
+} & import("headless/types/utils/index.js").CommonUtils & import("headless/types/utils/index.js").PluginUtils & {
+    isDomainWhitelisted(whitelist: string[], url: string | URL): boolean;
+    isDomainAllowed(url: string | URL, setting: string): boolean;
+    isMediaURLDomainAllowed(o: MediaURLData): boolean;
+    shouldRenderMediaFromURL(url_text: string, type: "audio" | "image" | "video"): any;
+    filterQueryParamsFromURL(url: string): string;
+    getNameAndValue(field: HTMLInputElement | HTMLSelectElement): {
+        [key: string]: string | number | string[];
+    } | null;
+    getFileName(url: string): string;
+    hasClass(className: string, el: Element): boolean;
+    addClass(className: string, el: Element): Element;
+    removeClass(className: string, el: Element): Element;
+    removeElement(el: Element): Element;
+    ancestor(el: HTMLElement, selector: string): HTMLElement;
+    getHyperlinkTemplate(url: string): TemplateResult | string;
+    slideOut(el: HTMLElement, duration?: number): Promise<any>;
+    slideIn(el: HTMLElement, duration?: number): Promise<any>;
+    xFormField2TemplateResult(xfield: import("headless/shared/types.js").XFormField, options?: any): TemplateResult;
+    getOuterWidth(el: HTMLElement, include_margin?: boolean): number;
+    getRootElement(): any;
+    default: {
+        getRandomInt: typeof import("headless/types/utils/index.js").getRandomInt;
+        getUniqueId: typeof import("headless/types/utils/index.js").getUniqueId;
+        isEmptyMessage: typeof import("headless/types/utils/index.js").isEmptyMessage;
+        onMultipleEvents: (events: any[], callback: Function) => void;
+        prefixMentions: typeof import("headless/types/utils/index.js").prefixMentions;
+        shouldCreateMessage: (attrs: any) => any;
+        triggerEvent: (el: Element, name: string, type?: string, bubbles?: boolean, cancelable?: boolean) => void;
+        isValidURL(text: string): boolean;
+        getURL(url: string | URL): URL;
+        checkFileTypes(types: string[], url: string | URL): boolean;
+        isURLWithImageExtension(url: string | URL): boolean;
+        isGIFURL(url: string | URL): boolean;
+        isAudioURL(url: string | URL): boolean;
+        isVideoURL(url: string | URL): boolean;
+        isImageURL(url: string | URL): boolean;
+        isEncryptedFileURL(url: string | URL): boolean;
+        getMediaURLsMetadata(text: string, offset?: number): {
+            media_urls?: import("headless/types/utils/types.js").MediaURLMetadata[];
+        };
+        getMediaURLs(arr: Array<import("headless/types/utils/types.js").MediaURLMetadata>, text: string): import("headless/types/utils/types.js").MediaURLMetadata[];
+        addMediaURLsOffset(arr: Array<import("headless/types/utils/types.js").MediaURLMetadata>, text: string, offset?: number): import("headless/types/utils/types.js").MediaURLMetadata[];
+        firstCharToUpperCase(text: string): string;
+        getLongestSubstring(string: string, candidates: string[]): string;
+        isString(s: any): boolean;
+        getDefaultStore(): "session" | "persistent";
+        createStore(id: any, store: any): any;
+        initStorage(model: any, id: any, type: any): void;
+        isErrorStanza(stanza: Element): boolean;
+        isForbiddenError(stanza: Element): boolean;
+        isServiceUnavailableError(stanza: Element): boolean;
+        getAttributes(stanza: Element): object;
+        toStanza: typeof import("strophe.js").Stanza.toElement;
+        isUniView(): boolean;
+        isTestEnv(): boolean;
+        getUnloadEvent(): "pagehide" | "beforeunload" | "unload";
+        replacePromise(_converse: ConversePrivateGlobal, name: string): void;
+        shouldClearCache(_converse: ConversePrivateGlobal): boolean;
+        tearDown(_converse: ConversePrivateGlobal): Promise<any>;
+        clearSession(_converse: ConversePrivateGlobal): any;
+        waitUntil(func: Function, max_wait?: number, check_delay?: number): Promise<any>;
+        getOpenPromise: any;
+        merge(dst: any, src: any): void;
+        isError(obj: unknown): boolean;
+        isFunction(val: unknown): boolean;
+        isUndefined(x: unknown): boolean;
+        isErrorObject(o: unknown): boolean;
+        isPersistableModel(model: import("@converse/skeletor").Model): boolean;
+        isValidJID(jid?: string | null): boolean;
+        isValidMUCJID(jid: string): boolean;
+        isSameBareJID(jid1: string, jid2: string): boolean;
+        isSameDomain(jid1: string, jid2: string): boolean;
+        getJIDFromURI(jid: string): string;
+        initPlugins(_converse: ConversePrivateGlobal): void;
+        initClientConfig(_converse: ConversePrivateGlobal): Promise<void>;
+        initSessionStorage(_converse: ConversePrivateGlobal): Promise<void>;
+        initPersistentStorage(_converse: ConversePrivateGlobal, store_name: string, key?: string): void;
+        setUserJID(jid: string): Promise<string>;
+        initSession(_converse: ConversePrivateGlobal, jid: string): Promise<void>;
+        registerGlobalEventHandlers(_converse: ConversePrivateGlobal): void;
+        cleanup(_converse: ConversePrivateGlobal): Promise<void>;
+        attemptNonPreboundSession(credentials?: import("headless/types/utils/types.js").Credentials, automatic?: boolean): Promise<void>;
+        savedLoginInfo(jid: string): Promise<import("@converse/skeletor").Model>;
+        safeSave(model: import("@converse/skeletor").Model, attributes: any, options: any): void;
+        isElement(el: unknown): boolean;
+        isTagEqual(stanza: Element | typeof import("strophe.js").Builder, name: string): boolean;
+        stringToElement(s: string): Element;
+        queryChildren(el: HTMLElement, selector: string): ChildNode[];
+        siblingIndex(el: Element): number;
+        decodeHTMLEntities(str: string): string;
+        getSelectValues(select: HTMLSelectElement): string[];
+        webForm2xForm(field: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement): Element;
+        getCurrentWord(input: HTMLInputElement | HTMLTextAreaElement, index?: number, delineator?: string | RegExp): string;
+        isMentionBoundary(s: string): boolean;
+        replaceCurrentWord(input: HTMLInputElement, new_value: string): void;
+        placeCaretAtEnd(textarea: HTMLTextAreaElement): void;
+        colorize(s: string): Promise<string>;
+        appendArrayBuffer(buffer1: any, buffer2: any): ArrayBufferLike;
+        arrayBufferToHex(ab: any): any;
+        arrayBufferToString(ab: any): string;
+        stringToArrayBuffer(string: any): ArrayBufferLike;
+        arrayBufferToBase64(ab: any): string;
+        base64ToArrayBuffer(b64: any): ArrayBufferLike;
+        hexToArrayBuffer(hex: any): ArrayBufferLike;
+        unique<T extends unknown>(arr: Array<T>): Array<T>;
+    } & import("headless/types/utils/index.js").CommonUtils & import("headless/types/utils/index.js").PluginUtils;
+    isImageWithAlphaChannel(image_file: File): Promise<boolean>;
+    compressImage(file: File, options?: CompressionOptions): Promise<Blob>;
+    MIMETYPES_MAP: {
+        aac: string;
+        abw: string;
+        arc: string;
+        avi: string;
+        azw: string;
+        bin: string;
+        bmp: string;
+        bz: string;
+        bz2: string;
+        cda: string;
+        csh: string;
+        css: string;
+        csv: string;
+        doc: string;
+        docx: string;
+        eot: string;
+        epub: string;
+        gif: string;
+        gz: string;
+        htm: string;
+        html: string;
+        ico: string;
+        ics: string;
+        jar: string;
+        jpeg: string;
+        jpg: string;
+        js: string;
+        json: string;
+        jsonld: string;
+        m4a: string;
+        mid: string;
+        midi: string;
+        mjs: string;
+        mp3: string;
+        mp4: string;
+        mpeg: string;
+        mpkg: string;
+        odp: string;
+        ods: string;
+        odt: string;
+        oga: string;
+        ogv: string;
+        ogx: string;
+        opus: string;
+        otf: string;
+        png: string;
+        pdf: string;
+        php: string;
+        ppt: string;
+        pptx: string;
+        rar: string;
+        rtf: string;
+        sh: string;
+        svg: string;
+        swf: string;
+        tar: string;
+        tif: string;
+        tiff: string;
+        ts: string;
+        ttf: string;
+        txt: string;
+        vsd: string;
+        wav: string;
+        weba: string;
+        webm: string;
+        webp: string;
+        woff: string;
+        woff2: string;
+        xhtml: string;
+        xls: string;
+        xlsx: string;
+        xml: string;
+        xul: string;
+        zip: string;
+        '3gp': string;
+        '3g2': string;
+        '7z': string;
+    };
+    getAuthorStyle(occupant: any): string | TemplateResult;
+};
+export default _default;
+import * as url from "./url.js";
+import * as html from "./html.js";
+import * as file from "./file.js";
+import * as color from "./color.js";
+//# sourceMappingURL=index.d.ts.map

+ 17 - 2
src/types/utils/url.d.ts

@@ -1,5 +1,13 @@
-export function isDomainWhitelisted(whitelist: any, url: any): any;
-export function isDomainAllowed(url: any, setting: any): any;
+/**
+ * @param {string[]} whitelist
+ * @param {string|URL} url
+ */
+export function isDomainWhitelisted(whitelist: string[], url: string | URL): boolean;
+/**
+ * @param {string|URL} url
+ * @param {string} setting
+ */
+export function isDomainAllowed(url: string | URL, setting: string): boolean;
 /**
 /**
  * Accepts a {@link MediaURLData} object and then checks whether its domain is
  * Accepts a {@link MediaURLData} object and then checks whether its domain is
  * allowed for rendering in the chat.
  * allowed for rendering in the chat.
@@ -12,5 +20,12 @@ export function isMediaURLDomainAllowed(o: MediaURLData): boolean;
  * @param {"audio"|"image"|"video"} type
  * @param {"audio"|"image"|"video"} type
  */
  */
 export function shouldRenderMediaFromURL(url_text: string, type: "audio" | "image" | "video"): any;
 export function shouldRenderMediaFromURL(url_text: string, type: "audio" | "image" | "video"): any;
+/**
+ * Takes the `filter_url_query_params` array from the settings and
+ * removes any query strings from the URL that matches those values.
+ * @param {string} url
+ * @return {string}
+ */
+export function filterQueryParamsFromURL(url: string): string;
 export type MediaURLData = any;
 export type MediaURLData = any;
 //# sourceMappingURL=url.d.ts.map
 //# sourceMappingURL=url.d.ts.map

+ 30 - 16
src/utils/html.js

@@ -19,9 +19,9 @@ import tplFormUsername from '../templates/form_username.js';
 import tplHyperlink from 'templates/hyperlink.js';
 import tplHyperlink from 'templates/hyperlink.js';
 
 
 const { sizzle, Strophe, dayjs } = converse.env;
 const { sizzle, Strophe, dayjs } = converse.env;
-const { getURI, isValidURL } = u;
+const { isValidURL } = u;
 
 
-const APPROVED_URL_PROTOCOLS = ['http', 'https', 'xmpp', 'mailto'];
+const APPROVED_URL_PROTOCOLS = ['http:', 'https:', 'xmpp:', 'mailto:'];
 
 
 const EMPTY_TEXT_REGEX = /\s*\n\s*/;
 const EMPTY_TEXT_REGEX = /\s*\n\s*/;
 
 
@@ -133,13 +133,16 @@ function slideOutWrapup (el) {
     el.style.height = '';
     el.style.height = '';
 }
 }
 
 
+/**
+ * @param {string} url
+ */
 export function getFileName (url) {
 export function getFileName (url) {
-    const uri = getURI(url);
     try {
     try {
-        return decodeURI(uri.filename());
+        const uri = u.getURL(url);
+        return decodeURI(uri.pathname.split('/').pop());
     } catch (error) {
     } catch (error) {
         log.debug(error);
         log.debug(error);
-        return uri.filename();
+        return url;
     }
     }
 }
 }
 
 
@@ -159,6 +162,10 @@ function calculateElementHeight (el) {
     }, 0);
     }, 0);
 }
 }
 
 
+/**
+ * @param {HTMLElement} el
+ * @param {string} selector
+ */
 function getNextElement (el, selector = '*') {
 function getNextElement (el, selector = '*') {
     let next_el = el.nextElementSibling;
     let next_el = el.nextElementSibling;
     while (next_el !== null && !sizzle.matchesSelector(next_el, selector)) {
     while (next_el !== null && !sizzle.matchesSelector(next_el, selector)) {
@@ -169,8 +176,8 @@ function getNextElement (el, selector = '*') {
 
 
 /**
 /**
  * Has an element a class?
  * Has an element a class?
- * @param { string } className
- * @param { Element } el
+ * @param {string} className
+ * @param {Element} el
  */
  */
 export function hasClass (className, el) {
 export function hasClass (className, el) {
     return el instanceof Element && el.classList.contains(className);
     return el instanceof Element && el.classList.contains(className);
@@ -178,8 +185,8 @@ export function hasClass (className, el) {
 
 
 /**
 /**
  * Add a class to an element.
  * Add a class to an element.
- * @param { string } className
- * @param { Element } el
+ * @param {string} className
+ * @param {Element} el
  */
  */
 export function addClass (className, el) {
 export function addClass (className, el) {
     el instanceof Element && el.classList.add(className);
     el instanceof Element && el.classList.add(className);
@@ -188,8 +195,8 @@ export function addClass (className, el) {
 
 
 /**
 /**
  * Remove a class from an element.
  * Remove a class from an element.
- * @param { string } className
- * @param { Element } el
+ * @param {string} className
+ * @param {Element} el
  */
  */
 export function removeClass (className, el) {
 export function removeClass (className, el) {
     el instanceof Element && el.classList.remove(className);
     el instanceof Element && el.classList.remove(className);
@@ -198,7 +205,7 @@ export function removeClass (className, el) {
 
 
 /**
 /**
  * Remove an element from its parent
  * Remove an element from its parent
- * @param { Element } el
+ * @param {Element} el
  */
  */
 export function removeElement (el) {
 export function removeElement (el) {
     el instanceof Element && el.parentNode && el.parentNode.removeChild(el);
     el instanceof Element && el.parentNode && el.parentNode.removeChild(el);
@@ -279,6 +286,9 @@ function escapeHTML (string) {
         .replace(/"/g, '&quot;');
         .replace(/"/g, '&quot;');
 }
 }
 
 
+/**
+ * @param {string} protocol
+ */
 function isProtocolApproved (protocol, safeProtocolsList = APPROVED_URL_PROTOCOLS) {
 function isProtocolApproved (protocol, safeProtocolsList = APPROVED_URL_PROTOCOLS) {
     return !!safeProtocolsList.includes(protocol);
     return !!safeProtocolsList.includes(protocol);
 }
 }
@@ -289,11 +299,15 @@ function isProtocolApproved (protocol, safeProtocolsList = APPROVED_URL_PROTOCOL
  */
  */
 export function getHyperlinkTemplate (url) {
 export function getHyperlinkTemplate (url) {
     const http_url = RegExp('^w{3}.', 'ig').test(url) ? `http://${url}` : url;
     const http_url = RegExp('^w{3}.', 'ig').test(url) ? `http://${url}` : url;
-    const uri = getURI(url);
-    if (uri !== null && isValidURL(http_url) && (isProtocolApproved(uri._parts.protocol) || !uri._parts.protocol)) {
-        return tplHyperlink(uri, url);
-    }
+    try {
+        const uri = u.getURL(http_url);
+        if (isProtocolApproved(uri.protocol)) {
+            return tplHyperlink(uri, url);
+        }
     return url;
     return url;
+    } catch (error) {
+        log.debug(error);
+    }
 }
 }
 
 
 /**
 /**

+ 15 - 0
src/utils/index.js

@@ -0,0 +1,15 @@
+import { u } from "@converse/headless";
+import * as color from "./color.js";
+import * as file from "./file.js";
+import * as html from "./html.js";
+import * as url from "./url.js";
+
+export default Object.assign(
+    u,
+    {
+        ...color,
+        ...file,
+        ...html,
+        ...url,
+    },
+);

+ 73 - 0
src/utils/tests/url.js

@@ -0,0 +1,73 @@
+/* global api, log, converse */
+const { isDomainWhitelisted, isDomainAllowed, isMediaURLDomainAllowed, shouldRenderMediaFromURL } = converse.env.utils;
+
+describe("URL Utility Functions", () => {
+    describe("isDomainWhitelisted", () => {
+        it("should return true for exact domain match", () => {
+            const whitelist = ["example.com"];
+            expect(isDomainWhitelisted(whitelist, "https://example.com/path")).toBe(true);
+        });
+
+        it("should return true for subdomain match", () => {
+            const whitelist = ["example.com"];
+            expect(isDomainWhitelisted(whitelist, "https://sub.example.com/path")).toBe(true);
+        });
+
+        it("should return false for non-matching domain", () => {
+            const whitelist = ["example.com"];
+            expect(isDomainWhitelisted(whitelist, "https://other.com/path")).toBe(false);
+        });
+
+        it("should handle multiple whitelist entries", () => {
+            const whitelist = ["example.com", "test.org"];
+            expect(isDomainWhitelisted(whitelist, "https://test.org/path")).toBe(true);
+        });
+    });
+
+    describe("isDomainAllowed", () => {
+
+        it("should return true when setting is not an array",
+            mock.initConverse(
+                ['chatBoxesFetched'], {'allowed_video_domains': 'conversejs.org'},
+                async function (_converse) {
+
+            expect(isDomainAllowed("https://conversejs.org", "allowed_domains")).toBe(true);
+        }));
+
+        it("should return false for non-allowed domain",
+            mock.initConverse(
+                ['chatBoxesFetched'], {'allowed_video_domains': ['conversejs.org']},
+                async function (_converse) {
+
+            expect(isDomainAllowed("https://conversejs.com", "allowed_video_domains")).toBe(false);
+        }));
+
+        it("should return false for invalid URL",
+            mock.initConverse(
+                ['chatBoxesFetched'], {'allowed_video_domains': ['conversejs.org']},
+                async function (_converse) {
+            expect(isDomainAllowed("invalid-url", "allowed_video_domains")).toBe(false);
+        }));
+    });
+
+    describe("shouldRenderMediaFromURL", () => {
+        it("should allow http/https protocols",
+            mock.initConverse(
+                ['chatBoxesFetched'], { allowed_image_domains: ['conversejs.org']},
+                async function (_converse) {
+            expect(shouldRenderMediaFromURL("https://conversejs.org/img.jpg", "image")).toBe(true);
+            expect(shouldRenderMediaFromURL("http://conversejs.org/img.jpg", "image")).toBe(true);
+        }));
+
+        it("should allow chrome-extension and file protocols",
+            mock.initConverse(
+                ['chatBoxesFetched'], { allowed_image_domains: ['conversejs.org']},
+                async function (_converse) {
+            expect(shouldRenderMediaFromURL("chrome-extension://conversejs.org/img.jpg", "image")).toBe(true);
+        }));
+
+        it("should reject other protocols", () => {
+            expect(shouldRenderMediaFromURL("ftp://image.com/img.jpg", "image")).toBe(false);
+        });
+    });
+});

+ 46 - 21
src/utils/url.js

@@ -1,19 +1,26 @@
 /**
 /**
  * @typedef {module:headless-shared-chat-utils.MediaURLData} MediaURLData
  * @typedef {module:headless-shared-chat-utils.MediaURLData} MediaURLData
  */
  */
-import { api, log, u } from '@converse/headless';
+import { api, log, u } from "@converse/headless";
 
 
-const { getURI } = u;
-
-export function isDomainWhitelisted (whitelist, url) {
-    const uri = getURI(url);
-    const subdomain = uri.subdomain();
-    const domain = uri.domain();
-    const fulldomain = `${subdomain ? `${subdomain}.` : ''}${domain}`;
+/**
+ * @param {string[]} whitelist
+ * @param {string|URL} url
+ */
+export function isDomainWhitelisted(whitelist, url) {
+    const uri = u.getURL(url);
+    const parts = uri.hostname.split('.');
+    const domain = parts.slice(-2).join('.'); // Get the last two parts for domain and TLD
+    const subdomain = parts.slice(0, -2).join('.'); // Get everything before the last two parts
+    const fulldomain = `${subdomain ? `${subdomain}.` : ""}${domain}`;
     return whitelist.includes(domain) || whitelist.includes(fulldomain);
     return whitelist.includes(domain) || whitelist.includes(fulldomain);
 }
 }
 
 
-export function isDomainAllowed (url, setting) {
+/**
+ * @param {string|URL} url
+ * @param {string} setting
+ */
+export function isDomainAllowed(url, setting) {
     const allowed_domains = api.settings.get(setting);
     const allowed_domains = api.settings.get(setting);
     if (!Array.isArray(allowed_domains)) {
     if (!Array.isArray(allowed_domains)) {
         return true;
         return true;
@@ -32,10 +39,12 @@ export function isDomainAllowed (url, setting) {
  * @param {MediaURLData} o
  * @param {MediaURLData} o
  * @returns {boolean}
  * @returns {boolean}
  */
  */
-export function isMediaURLDomainAllowed (o) {
-    return o.is_audio && isDomainAllowed(o.url, 'allowed_audio_domains') ||
-        o.is_video && isDomainAllowed(o.url, 'allowed_video_domains') ||
-        o.is_image && isDomainAllowed(o.url, 'allowed_image_domains');
+export function isMediaURLDomainAllowed(o) {
+    return (
+        (o.is_audio && isDomainAllowed(o.url, "allowed_audio_domains")) ||
+        (o.is_video && isDomainAllowed(o.url, "allowed_video_domains")) ||
+        (o.is_image && isDomainAllowed(o.url, "allowed_image_domains"))
+    );
 }
 }
 
 
 /**
 /**
@@ -44,15 +53,14 @@ export function isMediaURLDomainAllowed (o) {
  * @param {string} url
  * @param {string} url
  * @returns {boolean}
  * @returns {boolean}
  */
  */
-function isAllowedProtocolForMedia (url) {
-    const uri = getURI(url);
+function isAllowedProtocolForMedia(url) {
     const { protocol } = window.location;
     const { protocol } = window.location;
-    if (['chrome-extension:','file:'].includes(protocol)) {
+    if (["chrome-extension:", "file:"].includes(protocol)) {
         return true;
         return true;
     }
     }
+    const uri = u.getURL(url);
     return (
     return (
-        protocol === 'http:' ||
-        (protocol === 'https:' && ['https', 'aesgcm'].includes(uri.protocol().toLowerCase()))
+        protocol === "http:" || (protocol === "https:" && ["https", "aesgcm"].includes(uri.protocol.toLowerCase()))
     );
     );
 }
 }
 
 
@@ -60,16 +68,33 @@ function isAllowedProtocolForMedia (url) {
  * @param {string} url_text
  * @param {string} url_text
  * @param {"audio"|"image"|"video"} type
  * @param {"audio"|"image"|"video"} type
  */
  */
-export function shouldRenderMediaFromURL (url_text, type) {
+export function shouldRenderMediaFromURL(url_text, type) {
     if (!isAllowedProtocolForMedia(url_text)) {
     if (!isAllowedProtocolForMedia(url_text)) {
         return false;
         return false;
     }
     }
-    const may_render = api.settings.get('render_media');
+    const may_render = api.settings.get("render_media");
     const is_domain_allowed = isDomainAllowed(url_text, `allowed_${type}_domains`);
     const is_domain_allowed = isDomainAllowed(url_text, `allowed_${type}_domains`);
 
 
     if (Array.isArray(may_render)) {
     if (Array.isArray(may_render)) {
-        return is_domain_allowed && isDomainWhitelisted (may_render, url_text);
+        return is_domain_allowed && isDomainWhitelisted(may_render, url_text);
     } else {
     } else {
         return is_domain_allowed && may_render;
         return is_domain_allowed && may_render;
     }
     }
 }
 }
+
+/**
+ * Takes the `filter_url_query_params` array from the settings and
+ * removes any query strings from the URL that matches those values.
+ * @param {string} url
+ * @return {string}
+ */
+export function filterQueryParamsFromURL(url) {
+    const setting = api.settings.get("filter_url_query_params");
+    if (!setting) return url;
+
+    const to_remove = Array.isArray(setting) ? setting : [setting];
+    const url_obj = u.getURL(url);
+    to_remove.forEach(/** @param {string} p */(p) => url_obj.searchParams.delete(p));
+
+    return url_obj.toString();
+}