Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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": "/?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
206 changes: 206 additions & 0 deletions src/plugins/chatview/tests/query-actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/*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
*/
it("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 originalLocation = window.location;
const originalReplaceState = window.history.replaceState;

// Mock window.location to simulate a protocol handler URI
// This simulates: ?uri=xmpp:[email protected]
delete window.location;
window.location = {
search: '?uri=xmpp%3Aromeo%40montague.lit', // URL-encoded: xmpp:[email protected]
hash: '',
origin: 'http://localhost',
pathname: '/',
};

// Spy on history.replaceState to verify URL cleanup
const replaceStateSpy = jasmine.createSpy('replaceState');
window.history.replaceState = replaceStateSpy;

try {
// Import the function to test
const { routeToQueryAction } = await import('../utils.js');

// Call the function - this should parse URI and open chat
await routeToQueryAction();

// Verify that the URL was cleaned up (protocol handler removes ?uri=...)
expect(replaceStateSpy).toHaveBeenCalledWith(
{},
document.title,
'http://localhost/'
);

// Wait for and verify that a chatbox was created
await u.waitUntil(() => _converse.chatboxes.get('[email protected]'));
const chatbox = _converse.chatboxes.get('[email protected]');
expect(chatbox).toBeDefined();
expect(chatbox.get('jid')).toBe('[email protected]');
} finally {
// Restore original globals to avoid test pollution
window.location = originalLocation;
window.history.replaceState = originalReplaceState;
}
}));

/**
* Test message sending functionality when action=message
* This tests URI parsing, chat opening, and message sending
*/
it("sends a message when action=message with body",
mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {

const { api } = _converse;
await mock.waitForRoster(_converse, 'current', 1);

const originalLocation = window.location;
const originalReplaceState = window.history.replaceState;

// Mock URI with message action: ?uri=xmpp:[email protected]?action=message&body=Hello
delete window.location;
window.location = {
search: '?uri=xmpp%3Aromeo%40montague.lit%3Faction%3Dmessage%26body%3DHello',
hash: '',
origin: 'http://localhost',
pathname: '/',
};

window.history.replaceState = jasmine.createSpy('replaceState');

try {
const { routeToQueryAction } = await import('../utils.js');

// Spy on the connection send method to verify XMPP stanza sending
spyOn(api.connection.get(), 'send');

// Execute the function
await routeToQueryAction();

// Verify chat was opened
await u.waitUntil(() => _converse.chatboxes.get('[email protected]'));
const chatbox = _converse.chatboxes.get('[email protected]');
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 = originalLocation;
window.history.replaceState = originalReplaceState;
}
}));

/**
* Test error handling for invalid JIDs
* This ensures the function doesn't crash and handles invalid input gracefully
*/
it("handles invalid JID gracefully",
mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {

const originalLocation = window.location;
const originalReplaceState = window.history.replaceState;

// Mock URI with invalid JID format
delete window.location;
window.location = {
search: '?uri=xmpp%3Ainvalid-jid',
hash: '',
origin: 'http://localhost',
pathname: '/',
};

window.history.replaceState = jasmine.createSpy('replaceState');

try {
const { routeToQueryAction } = await import('../utils.js');

// Record initial chatbox count
const initialCount = _converse.chatboxes.length;

// Function should not throw an error, just log warning and return
await routeToQueryAction();

// Verify no new chatbox was created for invalid JID
expect(_converse.chatboxes.length).toBe(initialCount);

// URL should still be cleaned up even for invalid JIDs
expect(window.history.replaceState).toHaveBeenCalled();
} finally {
window.location = originalLocation;
window.history.replaceState = originalReplaceState;
}
}));

/**
* Test roster contact addition (action=add-roster)
* This tests the contact management functionality
*/
it("adds contact to roster when action=add-roster",
mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {

const { api } = _converse;
await mock.waitForRoster(_converse, 'current', 1);
await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], []);

const originalLocation = window.location;
const originalReplaceState = window.history.replaceState;

// Mock URI with roster action: ?uri=xmpp:[email protected]?action=add-roster&name=John&group=Friends
delete window.location;
window.location = {
search: '?uri=xmpp%3Anewcontact%40montague.lit%3Faction%3Dadd-roster%26name%3DJohn%26group%3DFriends',
hash: '',
origin: 'http://localhost',
pathname: '/',
};

window.history.replaceState = jasmine.createSpy('replaceState');

try {
const { routeToQueryAction } = await import('../utils.js');

// Spy on connection send to verify roster IQ stanza
spyOn(api.connection.get(), 'send');

await routeToQueryAction();

// Wait for roster IQ to be sent
await u.waitUntil(() => api.connection.get().send.calls.count() > 0);

// Verify the roster addition IQ was sent
const sent_stanzas = api.connection.get().send.calls.all().map(call => call.args[0]);
const roster_iq = sent_stanzas.find(s =>
s.querySelector &&
s.querySelector('iq[type="set"] query[xmlns="jabber:iq:roster"]')
);
expect(roster_iq).toBeDefined();

// Verify roster item details
const item = roster_iq.querySelector('item');
expect(item.getAttribute('jid')).toBe('[email protected]');
expect(item.getAttribute('name')).toBe('John');
expect(item.querySelector('group').textContent).toBe('Friends');
} finally {
window.location = originalLocation;
window.history.replaceState = originalReplaceState;
}
}));
});
119 changes: 119 additions & 0 deletions 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 log from "@converse/log";


export function clearHistory (jid) {
if (location.hash === `converse/chat?jid=${jid}`) {
Expand Down Expand Up @@ -71,3 +73,120 @@ 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:[email protected]?action=message&body=Hello
* xmpp:[email protected]?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, queryParams } = parseXMPPURI(uri);
if (!u.isValidJID(jid)) {
return log.warn(`routeToQueryAction: Invalid JID: "${jid}"`);
}

const action = queryParams.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, queryParams);
break;

case 'add-roster':
await handleRosterAction(jid, queryParams);
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;

// Case 1: protocol handler (?uri=...)
const searchParams = new URLSearchParams(window.location.search);
uri = searchParams.get('uri');

// Case 2: hash-based (#converse/action?uri=...)
if (!uri && 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 queryParams = new URLSearchParams(query);
return { jid, queryParams };
}

/**
* 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) {
await api.waitUntil('rosterContactsFetched');

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);
}
}
Loading