diff --git a/CHANGES.md b/CHANGES.md
index cadab746cf..e06f920592 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -17,6 +17,7 @@
- #3769: Various OMEMO fixes
- #3791: Fetching pubsub node configuration fails
- #3792: Node reconfiguration attempt uses incorrect field names
+- Adds support for opening XMPP URIs in Converse and for XEP-0147 query actions
- Fix documentation formatting in security.rst
- Add approval banner in chats with requesting contacts or unsaved contacts
- Add mongolian as a language option
diff --git a/conversejs.doap b/conversejs.doap
index 53a52ddd5a..7b11c0436a 100644
--- a/conversejs.doap
+++ b/conversejs.doap
@@ -109,6 +109,15 @@
+
+
+
+ partial
+ 1.2
+ 12.0.1
+ Supports XMPP URI scheme with query actions for message and roster management
+
+
diff --git a/manifest.json b/manifest.json
index 4c6f9c6a7d..fce4bbf74e 100644
--- a/manifest.json
+++ b/manifest.json
@@ -26,5 +26,11 @@
"background_color": "#397491",
"display": "standalone",
"scope": "/",
- "theme_color": "#397491"
+ "theme_color": "#397491",
+ "protocol_handlers": [
+ {
+ "protocol": "xmpp",
+ "url": "#converse/action?uri=%s"
+ }
+ ]
}
diff --git a/src/plugins/chatview/index.js b/src/plugins/chatview/index.js
index a9fa389d0d..574b79f95d 100644
--- a/src/plugins/chatview/index.js
+++ b/src/plugins/chatview/index.js
@@ -8,7 +8,7 @@ import 'shared/chat/help-messages.js';
import 'shared/chat/toolbar.js';
import ChatView from './chat.js';
import { _converse, api, converse } from '@converse/headless';
-import { clearHistory } from './utils.js';
+import { clearHistory,routeToQueryAction } from './utils.js';
import './styles/index.scss';
@@ -65,6 +65,15 @@ converse.plugins.add('converse-chatview', {
Object.assign(_converse, exports); // DEPRECATED
Object.assign(_converse.exports, exports);
+ if ('registerProtocolHandler' in navigator) {
+ try {
+ const handlerUrl = `${window.location.origin}${window.location.pathname}#converse/action?uri=%s`;
+ navigator.registerProtocolHandler('xmpp', handlerUrl);
+ } catch (error) {
+ console.warn('Failed to register protocol handler:', error);
+ }
+ }
+ routeToQueryAction();
api.listen.on('connected', () => api.disco.own.features.add(Strophe.NS.SPOILER));
api.listen.on('chatBoxClosed', (model) => clearHistory(model.get('jid')));
}
diff --git a/src/plugins/chatview/tests/query-actions.js b/src/plugins/chatview/tests/query-actions.js
new file mode 100644
index 0000000000..c6bcc89415
--- /dev/null
+++ b/src/plugins/chatview/tests/query-actions.js
@@ -0,0 +1,167 @@
+/*global mock, converse */
+
+const { u } = converse.env;
+
+describe("XMPP URI Query Actions (XEP-0147)", function () {
+
+ /**
+ * Test the core functionality: opening a chat when no action is specified
+ * This tests the basic URI parsing and chat opening behavior
+ */
+ fit("opens a chat when URI has no action parameter",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const { api } = _converse;
+ // Wait for roster to be initialized so we can open chats
+ await mock.waitForRoster(_converse, 'current', 1);
+
+ // Save original globals to restore them later
+ const originalHash = window.location.hash;
+ const originalReplaceState = window.history.replaceState;
+
+ // Spy on history.replaceState to verify URL cleanup
+ const replaceStateSpy = jasmine.createSpy('replaceState');
+ window.history.replaceState = replaceStateSpy;
+
+ // Simulate a protocol handler URI by setting the hash
+ window.location.hash = '#converse/action?uri=xmpp%3Aromeo%40montague.lit';
+
+ try {
+ // Call the function - this should parse URI and open chat
+ await u.routeToQueryAction();
+
+ // Verify that the URL was cleaned up (protocol handler removes ?uri=...)
+ const expected_url = `${window.location.origin}${window.location.pathname}`;
+ expect(replaceStateSpy).toHaveBeenCalledWith({}, document.title, expected_url);
+
+ // Wait for and verify that a chatbox was created
+ await u.waitUntil(() => _converse.chatboxes.get('romeo@montague.lit'));
+ const chatbox = _converse.chatboxes.get('romeo@montague.lit');
+ expect(chatbox).toBeDefined();
+ expect(chatbox.get('jid')).toBe('romeo@montague.lit');
+ } finally {
+ // Restore original globals to avoid test pollution
+ window.location.hash = originalHash;
+ window.history.replaceState = originalReplaceState;
+ }
+ }));
+
+ /**
+ * Test message sending functionality when action=message
+ * This tests URI parsing, chat opening, and message sending
+ */
+ fit("sends a message when action=message with body",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current', 1);
+
+ const originalHash = window.location.hash;
+ const originalReplaceState = window.history.replaceState;
+
+ window.history.replaceState = jasmine.createSpy('replaceState');
+
+ // Mock URI with message action
+ window.location.hash = '#converse/action?uri=xmpp%3Aromeo%40montague.lit%3Faction%3Dmessage%26body%3DHello';
+
+ try {
+ // Spy on the connection send method to verify XMPP stanza sending
+ spyOn(api.connection.get(), 'send');
+
+ // Execute the function
+ await u.routeToQueryAction();
+
+ // Verify chat was opened
+ await u.waitUntil(() => _converse.chatboxes.get('romeo@montague.lit'));
+ const chatbox = _converse.chatboxes.get('romeo@montague.lit');
+ expect(chatbox).toBeDefined();
+
+ // Verify message was sent and stored in chat
+ await u.waitUntil(() => chatbox.messages.length > 0);
+ const message = chatbox.messages.at(0);
+ expect(message.get('message')).toBe('Hello');
+ expect(message.get('type')).toBe('chat');
+ } finally {
+ window.location.hash = originalHash;
+ window.history.replaceState = originalReplaceState;
+ }
+ }));
+
+ /**
+ * Test roster add functionality when action=add-roster
+ * This tests URI parsing and adding a contact to the roster
+ */
+ fit("adds a contact to roster when action=add-roster",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current', 1);
+
+ const originalHash = window.location.hash;
+ const originalReplaceState = window.history.replaceState;
+
+ window.history.replaceState = jasmine.createSpy('replaceState');
+
+ // Mock URI with add-roster action: ?uri=xmpp:juliet@capulet.lit?action=add-roster&name=Juliet&group=Friends
+ window.location.hash = '#converse/action?uri=xmpp%3Ajuliet%40capulet.lit%3Faction%3Dadd-roster%26name%3DJuliet%26group%3DFriends';
+
+ try {
+ // Spy on the contacts.add API method - return a resolved promise to avoid network calls
+ spyOn(api.contacts, 'add').and.returnValue(Promise.resolve());
+
+ // Execute the function
+ await u.routeToQueryAction();
+
+ // Verify that contacts.add was called with correct parameters
+ expect(api.contacts.add).toHaveBeenCalledWith(
+ {
+ jid: 'juliet@capulet.lit',
+ name: 'Juliet',
+ groups: ['Friends']
+ },
+ true, // persist on server
+ true, // subscribe to presence
+ '' // no custom message
+ );
+ } finally {
+ window.location.hash = originalHash;
+ window.history.replaceState = originalReplaceState;
+ }
+ }));
+
+ /**
+ * Test handling of invalid JIDs
+ * This ensures the function gracefully handles malformed JIDs
+ */
+ fit("handles invalid JID gracefully",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current', 1);
+
+ const originalHash = window.location.hash;
+ const originalReplaceState = window.history.replaceState;
+
+ window.history.replaceState = jasmine.createSpy('replaceState');
+
+ // Mock URI with invalid JID (missing domain)
+ window.location.hash = '#converse/action?uri=xmpp%3Ainvalid-jid';
+
+ try {
+ // Spy on api.chats.open to ensure it's NOT called for invalid JID
+ spyOn(api.chats, 'open');
+
+ // Execute the function
+ await u.routeToQueryAction();
+
+ // Verify that no chat was opened for the invalid JID
+ expect(api.chats.open).not.toHaveBeenCalled();
+
+ // Verify no chatbox was created
+ expect(_converse.chatboxes.get('invalid-jid')).toBeUndefined();
+ } finally {
+ window.location.hash = originalHash;
+ window.history.replaceState = originalReplaceState;
+ }
+ }));
+});
\ No newline at end of file
diff --git a/src/plugins/chatview/utils.js b/src/plugins/chatview/utils.js
index dcfd3151a5..e33082e24e 100644
--- a/src/plugins/chatview/utils.js
+++ b/src/plugins/chatview/utils.js
@@ -1,5 +1,7 @@
import { __ } from 'i18n';
-import { _converse, api } from '@converse/headless';
+import { _converse, api,u } from '@converse/headless';
+import log from "@converse/log";
+
export function clearHistory (jid) {
if (location.hash === `converse/chat?jid=${jid}`) {
@@ -71,3 +73,117 @@ export function resetElementHeight (ev) {
ev.target.style = '';
}
}
+
+
+/**
+ * Handle XEP-0147 "query actions" invoked via xmpp: URIs.
+ * Supports message sending, roster management, and future actions.
+ *
+ * Example URIs:
+ * xmpp:user@example.com?action=message&body=Hello
+ * xmpp:user@example.com?action=add-roster&name=John&group=Friends
+ */
+export async function routeToQueryAction(event) {
+ const { u } = _converse.env;
+
+ try {
+ const uri = extractXMPPURI(event);
+ if (!uri) return;
+
+ const { jid, query_params } = parseXMPPURI(uri);
+ if (!u.isValidJID(jid)) {
+ return log.warn(`routeToQueryAction: Invalid JID: "${jid}"`);
+ }
+
+ const action = query_params?.get('action');
+ if (!action) {
+ log.debug(`routeToQueryAction: No action specified, opening chat for "${jid}"`);
+ return api.chats.open(jid);
+ }
+
+ switch (action) {
+ case 'message':
+ await handleMessageAction(jid, query_params);
+ break;
+
+ case 'add-roster':
+ await handleRosterAction(jid, query_params);
+ break;
+
+ default:
+ log.warn(`routeToQueryAction: Unsupported XEP-0147 action: "${action}"`);
+ await api.chats.open(jid);
+ }
+ } catch (error) {
+ log.error('Failed to process XMPP query action:', error);
+ }
+}
+
+/**
+ * Extracts and decodes the xmpp: URI from the window location or hash.
+ */
+function extractXMPPURI(event) {
+ let uri = null;
+ // hash-based (#converse/action?uri=...)
+ if (location.hash.startsWith('#converse/action?uri=')) {
+ event?.preventDefault();
+ uri = location.hash.split('uri=').pop();
+ }
+
+ if (!uri) return null;
+
+ // Decode URI and remove xmpp: prefix
+ uri = decodeURIComponent(uri);
+ if (uri.startsWith('xmpp:')) uri = uri.slice(5);
+
+ // Clean up URL (remove ?uri=... for a clean view)
+ const cleanUrl = `${window.location.origin}${window.location.pathname}`;
+ window.history.replaceState({}, document.title, cleanUrl);
+
+ return uri;
+}
+
+/**
+ * Splits an xmpp: URI into a JID and query parameters.
+ */
+function parseXMPPURI(uri) {
+ const [jid, query] = uri.split('?');
+ const query_params = new URLSearchParams(query || '');
+ return { jid, query_params };
+}
+
+/**
+ * Handles the `action=message` case.
+ */
+async function handleMessageAction(jid, params) {
+ const body = params.get('body') || '';
+ const chat = await api.chats.open(jid);
+
+ if (body && chat) {
+ await chat.sendMessage({ body });
+ }
+}
+
+/**
+ * Handles the `action=add-roster` case.
+ */
+async function handleRosterAction(jid, params) {
+ const name = params.get('name') || jid.split('@')[0];
+ const group = params.get('group');
+ const groups = group ? [group] : [];
+
+ try {
+ await api.contacts.add(
+ { jid, name, groups },
+ true, // persist on server
+ true, // subscribe to presence
+ '' // no custom message
+ );
+ } catch (err) {
+ log.error(`Failed to add "${jid}" to roster:`, err);
+ }
+}
+
+Object.assign(u,{
+ routeToQueryAction,
+})
\ No newline at end of file