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

// Mock window.location to simulate a protocol handler URI
// This simulates: ?uri=xmpp:romeo@montague.lit
Object.defineProperty(window, "location", {
value: {
search: '?uri=xmpp%3Aromeo%40montague.lit', // URL-encoded: xmpp:romeo@montague.lit
hash: '',
origin: 'http://localhost',
pathname: '/',
},
configurable: true,
});

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

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=...)
expect(replaceStateSpy).toHaveBeenCalledWith(
{},
document.title,
'http://localhost/'
);

// 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
if (originalLocationDescriptor) {
Object.defineProperty(window, 'location', originalLocationDescriptor);
} else {
delete window.location;
}
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 originalLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location');
const originalReplaceState = window.history.replaceState;

// Mock URI with message action: ?uri=xmpp:romeo@montague.lit?action=message&body=Hello
Object.defineProperty(window, "location", {
value: {
search: '?uri=xmpp%3Aromeo%40montague.lit%3Faction%3Dmessage%26body%3DHello',
hash: '',
origin: 'http://localhost',
pathname: '/',
},
configurable: true,
});

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('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 {
if (originalLocationDescriptor) {
Object.defineProperty(window, 'location', originalLocationDescriptor);
} else {
delete window.location;
}
window.history.replaceState = originalReplaceState;
}
}));
});
120 changes: 119 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,119 @@
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, 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;
// 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 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) {
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);
}
}

Object.assign(u,{
routeToQueryAction,
})
Loading