Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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 @@ -16,6 +16,7 @@
- #3769: Various OMEMO fixes
- #3791: Fetching pubsub node configuration fails
- #3792: Node reconfiguration attempt uses incorrect field names
- Adding support for protocol handler
- 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.0</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:romeo@montague.lit
delete window.location;
window.location = {
search: '?uri=xmpp%3Aromeo%40montague.lit', // URL-encoded: xmpp:romeo@montague.lit
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('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 = 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:romeo@montague.lit?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('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 = 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:newcontact@montague.lit?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('newcontact@montague.lit');
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: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;

// 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