Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
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": "/dev?jid=%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
120 changes: 120 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,121 @@ 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(`Invalid JID: "${jid}"`);
}

const action = queryParams.get('action');
if (!action) {
log.debug(`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(`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('connected');
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