Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions conversejs.doap
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0144.html"/>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0147.html"/>
<xmpp:status>partial</xmpp:status>
<xmpp:version>1.2</xmpp:version>
<xmpp:since>12.0.1</xmpp:since>
<xmpp:note>Supports XMPP URI scheme with query actions for message and roster management</xmpp:note>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0153.html"/>
Expand Down
8 changes: 7 additions & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
11 changes: 10 additions & 1 deletion src/plugins/chatview/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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')));
}
Expand Down
167 changes: 167 additions & 0 deletions src/plugins/chatview/tests/query-actions.js
Original file line number Diff line number Diff line change
@@ -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;
}
}));
});
118 changes: 117 additions & 1 deletion src/plugins/chatview/utils.js
Original file line number Diff line number Diff line change
@@ -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}`) {
Expand Down Expand Up @@ -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,
})
Loading