-
-
Notifications
You must be signed in to change notification settings - Fork 801
Adding support for protocol handler #3870
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
2ba6ada
9c5f20d
5b41936
be0d75c
a32cec0
956b471
40f8f7f
29af0c9
80114ef
40538e3
71e9037
baaced6
ff769df
0e3175b
a22c1a0
8c91e48
cd88326
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } | ||
| })); | ||
| }); | ||
| 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}`) { | ||
|
|
@@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @marcellintacite Why is it necessary to put everything in a What happens when it's not there? Generally speaking, a try/catch should only be put around a single line or a small section of code that might be reasonably expected to throw an error that needs to be handled. |
||
| 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, | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@marcellintacite Please rename these functions so that they're all
it