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