From eea5bfd81a9034e7dfec35e5e6d69bf3896b26db Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Mon, 1 Jun 2026 15:11:52 -0300 Subject: [PATCH 1/5] feat(ui-voip): pre-fill media-call widget from desktop telephony deeplink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume `window.RocketChatDesktop.onTelephonyCallRequested` (forwarded `tel:`/`callto:` deeplinks and global-shortcut numbers from the Desktop app) and pre-populate the media-call dial-pad with the number. The user still starts the call. - useDesktopTelephonyListener: routes by widget state — closed opens the widget pre-filled, new sets the number, an in-progress call is ignored. - usePeerAutocomplete: reflect an externally-selected `{ number }` peer in the visible input (value is derived from userId only, so the field would otherwise stay empty). Fires on peerInfo identity change, preserving manual typing. - desktop-api: declare optional `onTelephonyCallRequested` on IRocketChatDesktop. Implemented in Rocket.Chat.Electron#3325. Cold-start replay (app closed when the deeplink fires) is handled on the Desktop side: the contract is replay-last-pending on subscribe. --- .../telephony-call-requested-desktop-api.md | 5 + packages/desktop-api/src/index.ts | 1 + .../src/context/usePeerAutocomplete.spec.tsx | 60 ++++++++++ .../src/context/usePeerAutocomplete.ts | 10 ++ .../src/providers/MediaCallViewProvider.tsx | 3 + .../useDesktopTelephonyListener.spec.tsx | 106 ++++++++++++++++++ .../providers/useDesktopTelephonyListener.ts | 49 ++++++++ 7 files changed, 234 insertions(+) create mode 100644 .changeset/telephony-call-requested-desktop-api.md create mode 100644 packages/ui-voip/src/providers/useDesktopTelephonyListener.spec.tsx create mode 100644 packages/ui-voip/src/providers/useDesktopTelephonyListener.ts diff --git a/.changeset/telephony-call-requested-desktop-api.md b/.changeset/telephony-call-requested-desktop-api.md new file mode 100644 index 0000000000000..c571a183b9165 --- /dev/null +++ b/.changeset/telephony-call-requested-desktop-api.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/desktop-api': minor +--- + +Add `onTelephonyCallRequested(callback)` to the `IRocketChatDesktop` type definition. Desktop clients can expose this method to forward `tel:`/`callto:` deeplink and global-shortcut phone numbers to the media-call widget. Implemented in Rocket.Chat.Electron#3325. diff --git a/packages/desktop-api/src/index.ts b/packages/desktop-api/src/index.ts index 9d10c1f89d1c5..4fc20e6e395c4 100644 --- a/packages/desktop-api/src/index.ts +++ b/packages/desktop-api/src/index.ts @@ -63,4 +63,5 @@ export interface IRocketChatDesktop { setUserToken: (token: string, userId: string) => void; openDocumentViewer: (url: string, format: string, options: any) => void; reloadServer: () => void; + onTelephonyCallRequested?: (callback: (payload: { phoneNumber: string; rawUri: string }) => void) => void; } diff --git a/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx b/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx index 6153410802d72..ef556c3cd8277 100644 --- a/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx +++ b/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx @@ -139,6 +139,66 @@ describe('hook', () => { expect(result.current.value).toBeUndefined(); }); + describe('external number sync', () => { + it('should reflect peerInfo.number in the filter (deeplink-forwarded number)', () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + const peerInfo: PeerInfo = { number: '312312313123' }; + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), { + wrapper: appRoot(), + }); + + expect(result.current.filter).toBe('312312313123'); + }); + + it('should not touch the filter when peerInfo has a userId', () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1' }; + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), { + wrapper: appRoot(), + }); + + expect(result.current.filter).toBe(''); + }); + + it('should update the filter when a new number arrives', () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + + const { result, rerender } = renderHook(({ peerInfo }) => usePeerAutocomplete(mockOnSelectPeer, peerInfo), { + wrapper: appRoot(), + initialProps: { peerInfo: { number: '111' } as PeerInfo }, + }); + + expect(result.current.filter).toBe('111'); + + rerender({ peerInfo: { number: '222' } as PeerInfo }); + + expect(result.current.filter).toBe('222'); + }); + + it('should preserve manual typing while peerInfo is unchanged', () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + const peerInfo: PeerInfo = { number: '111' }; + + const { result, rerender } = renderHook(({ peerInfo }) => usePeerAutocomplete(mockOnSelectPeer, peerInfo), { + wrapper: appRoot(), + initialProps: { peerInfo }, + }); + + expect(result.current.filter).toBe('111'); + + act(() => { + result.current.onChangeFilter('11199'); + }); + + // Same peerInfo identity -> sync effect must not fire and clobber the edit. + rerender({ peerInfo }); + + expect(result.current.filter).toBe('11199'); + }); + }); + describe('onChangeValue', () => { it('should do nothing if value is an array', async () => { mockGetAutocompleteOptions.mockResolvedValue([]); diff --git a/packages/ui-voip/src/context/usePeerAutocomplete.ts b/packages/ui-voip/src/context/usePeerAutocomplete.ts index fa7ab2c93f196..08ccc8b684ec0 100644 --- a/packages/ui-voip/src/context/usePeerAutocomplete.ts +++ b/packages/ui-voip/src/context/usePeerAutocomplete.ts @@ -39,6 +39,16 @@ export const usePeerAutocomplete = (onSelectPeer: (peerInfo: PeerInfo) => void, initialData: [], }); + // Reflect an externally-selected phone number (e.g. forwarded from a `tel:`/`callto:` deeplink + // by the Desktop app) in the visible input. `value` is derived from `userId` only, so a peer + // set as `{ number }` would otherwise leave the field empty. Fires on `peerInfo` identity change + // only, so manual typing is preserved and re-selecting the same number is a no-op. + useEffect(() => { + if (peerInfo && 'number' in peerInfo) { + setFilter(peerInfo.number); + } + }, [peerInfo]); + const status = useUserPresence(peerInfo && 'userId' in peerInfo ? peerInfo.userId : undefined); useEffect(() => { diff --git a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx index bbf30e8a9fdea..4a1ad043c92e2 100644 --- a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx +++ b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx @@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'; import { useCallSounds } from './useCallSounds'; import { useDesktopNotifications } from './useDesktopNotifications'; +import { useDesktopTelephonyListener } from './useDesktopTelephonyListener'; import { useMediaSession } from './useMediaSession'; import { useMediaSessionControls } from './useMediaSessionControls'; import { useScreenShareStreams } from './useScreenShareStreams'; @@ -43,6 +44,8 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { useDesktopNotifications(sessionState); + useDesktopTelephonyListener({ sessionState, toggleWidget, selectPeer }); + const setOutputMediaDevice = useSetOutputMediaDevice(); const setInputMediaDevice = useSetInputMediaDevice(); diff --git a/packages/ui-voip/src/providers/useDesktopTelephonyListener.spec.tsx b/packages/ui-voip/src/providers/useDesktopTelephonyListener.spec.tsx new file mode 100644 index 0000000000000..33b85cd936be1 --- /dev/null +++ b/packages/ui-voip/src/providers/useDesktopTelephonyListener.spec.tsx @@ -0,0 +1,106 @@ +import { renderHook } from '@testing-library/react'; + +import { useDesktopTelephonyListener } from './useDesktopTelephonyListener'; +import type { PeerInfo, SessionState } from '../context/definitions'; + +const baseSession = { + connectionState: 'CONNECTED', + peerInfo: undefined, + transferredBy: undefined, + muted: false, + held: false, + remoteMuted: false, + remoteHeld: false, + hidden: false, + supportedFeatures: [], +} as const; + +const emptySession = (state: 'closed' | 'new'): SessionState => ({ ...baseSession, state, callId: undefined }); + +const callSession = (state: 'calling' | 'ringing' | 'ongoing'): SessionState => ({ + ...baseSession, + state, + callId: 'call-id', + peerInfo: { number: '000' }, +}); + +type TelephonyCallback = (payload: { phoneNumber: string; rawUri: string }) => void; + +const setupDesktopBridge = () => { + let registered: TelephonyCallback | undefined; + const onTelephonyCallRequested = jest.fn((cb: TelephonyCallback) => { + registered = cb; + }); + + Object.defineProperty(window, 'RocketChatDesktop', { + value: { onTelephonyCallRequested }, + writable: true, + configurable: true, + }); + + return { + onTelephonyCallRequested, + fire: (phoneNumber: string) => registered?.({ phoneNumber, rawUri: `tel:${phoneNumber}` }), + }; +}; + +const clearDesktopBridge = () => { + Object.defineProperty(window, 'RocketChatDesktop', { + value: undefined, + writable: true, + configurable: true, + }); +}; + +const renderListener = (sessionState: SessionState) => { + const toggleWidget = jest.fn(); + const selectPeer = jest.fn(); + renderHook(() => useDesktopTelephonyListener({ sessionState, toggleWidget, selectPeer })); + return { toggleWidget, selectPeer }; +}; + +afterEach(() => { + clearDesktopBridge(); + jest.clearAllMocks(); +}); + +it('registers a single telephony callback on mount', () => { + const bridge = setupDesktopBridge(); + renderListener(emptySession('closed')); + expect(bridge.onTelephonyCallRequested).toHaveBeenCalledTimes(1); +}); + +it('opens the widget pre-filled when the widget is closed', () => { + const bridge = setupDesktopBridge(); + const { toggleWidget, selectPeer } = renderListener(emptySession('closed')); + + bridge.fire('+15551234567'); + + expect(toggleWidget).toHaveBeenCalledWith<[PeerInfo]>({ number: '+15551234567' }); + expect(selectPeer).not.toHaveBeenCalled(); +}); + +it('sets the number without re-toggling when the widget is already open and idle', () => { + const bridge = setupDesktopBridge(); + const { toggleWidget, selectPeer } = renderListener(emptySession('new')); + + bridge.fire('5551234567'); + + expect(selectPeer).toHaveBeenCalledWith<[PeerInfo]>({ number: '5551234567' }); + expect(toggleWidget).not.toHaveBeenCalled(); +}); + +it.each(['calling', 'ringing', 'ongoing'] as const)('ignores the request while a call is %s', (state) => { + const bridge = setupDesktopBridge(); + const { toggleWidget, selectPeer } = renderListener(callSession(state)); + + bridge.fire('5551234567'); + + expect(toggleWidget).not.toHaveBeenCalled(); + expect(selectPeer).not.toHaveBeenCalled(); +}); + +it('does nothing when the desktop bridge is unavailable', () => { + clearDesktopBridge(); + expect(() => renderListener(emptySession('closed'))).not.toThrow(); +}); diff --git a/packages/ui-voip/src/providers/useDesktopTelephonyListener.ts b/packages/ui-voip/src/providers/useDesktopTelephonyListener.ts new file mode 100644 index 0000000000000..ca623d6f53651 --- /dev/null +++ b/packages/ui-voip/src/providers/useDesktopTelephonyListener.ts @@ -0,0 +1,49 @@ +import { useEffect, useRef } from 'react'; + +import type { PeerInfo, SessionState } from '../context/definitions'; + +type TelephonyControls = { + sessionState: SessionState; + toggleWidget: (peerInfo?: PeerInfo) => void; + selectPeer: (peerInfo: PeerInfo) => void; +}; + +/** + * Listens for `tel:`/`callto:` deeplink and global-shortcut phone numbers forwarded + * by the Rocket.Chat Desktop app via `window.RocketChatDesktop.onTelephonyCallRequested`, + * and pre-populates the media-call widget with the number (the user still starts the call). + * + * Routing by current widget state: + * - `closed` -> open the widget pre-filled with the number + * - `new` -> widget already open and idle -> just set the number + * - otherwise -> a call is in progress, ignore the request + * + * The number is forwarded as-is; the Desktop app already strips formatting characters and + * no validation is applied on the dial-pad input (see DMV-1 / DMV-6). + */ +export const useDesktopTelephonyListener = ({ sessionState, toggleWidget, selectPeer }: TelephonyControls) => { + const controlsRef = useRef({ sessionState, toggleWidget, selectPeer }); + controlsRef.current = { sessionState, toggleWidget, selectPeer }; + + useEffect(() => { + if (typeof window.RocketChatDesktop?.onTelephonyCallRequested !== 'function') { + return; + } + + window.RocketChatDesktop.onTelephonyCallRequested(({ phoneNumber }) => { + const { sessionState, toggleWidget, selectPeer } = controlsRef.current; + const peerInfo: PeerInfo = { number: phoneNumber }; + + switch (sessionState.state) { + case 'closed': + toggleWidget(peerInfo); + break; + case 'new': + selectPeer(peerInfo); + break; + default: + break; + } + }); + }, []); +}; From ecde40c91143a0b47fa121e71376c7e3eb6fafad Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Mon, 1 Jun 2026 15:56:17 -0300 Subject: [PATCH 2/5] fix(ui-voip): keep dialed number in sync with manual dial-pad edits When a number peer is pre-filled (e.g. from a desktop telephony deeplink), editing the dial-pad input only changed local filter state, leaving sessionState.peerInfo.number stale. onCall then dialed the original number instead of the one shown. Sync the selected peer on onChangeFilter/onKeypadPress for number-dial peers so the call dials what the user sees. --- .../src/context/usePeerAutocomplete.spec.tsx | 50 +++++++++++++++++++ .../src/context/usePeerAutocomplete.ts | 14 +++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx b/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx index ef556c3cd8277..b65ceabefa4f2 100644 --- a/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx +++ b/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx @@ -199,6 +199,56 @@ describe('hook', () => { }); }); + describe('number peer edit sync', () => { + it('should re-select the number peer when the prefilled filter is manually edited', () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + const peerInfo: PeerInfo = { number: '111' }; + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), { + wrapper: appRoot(), + }); + + act(() => { + result.current.onChangeFilter('222'); + }); + + expect(result.current.filter).toBe('222'); + expect(mockOnSelectPeer).toHaveBeenCalledWith({ number: '222' }); + }); + + it('should re-select the number peer when editing via the keypad', () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + const peerInfo: PeerInfo = { number: '11' }; + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), { + wrapper: appRoot(), + }); + + act(() => { + result.current.onKeypadPress('9'); + }); + + expect(result.current.filter).toBe('119'); + expect(mockOnSelectPeer).toHaveBeenCalledWith({ number: '119' }); + }); + + it('should not re-select the peer when editing the filter for a userId peer', () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1' }; + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), { + wrapper: appRoot(), + }); + + act(() => { + result.current.onChangeFilter('typed'); + }); + + expect(result.current.filter).toBe('typed'); + expect(mockOnSelectPeer).not.toHaveBeenCalled(); + }); + }); + describe('onChangeValue', () => { it('should do nothing if value is an array', async () => { mockGetAutocompleteOptions.mockResolvedValue([]); diff --git a/packages/ui-voip/src/context/usePeerAutocomplete.ts b/packages/ui-voip/src/context/usePeerAutocomplete.ts index 08ccc8b684ec0..e9a79ece4f30b 100644 --- a/packages/ui-voip/src/context/usePeerAutocomplete.ts +++ b/packages/ui-voip/src/context/usePeerAutocomplete.ts @@ -49,6 +49,16 @@ export const usePeerAutocomplete = (onSelectPeer: (peerInfo: PeerInfo) => void, } }, [peerInfo]); + // When the dial-pad holds a phone-number peer (e.g. pre-filled from a deeplink), keep the + // selected peer in sync with manual edits so the call dials the number the user actually sees, + // not the original one. Status-based peers (`userId`) keep their existing selection. + const updateNumberFilter = (next: string) => { + setFilter(next); + if (peerInfo && 'number' in peerInfo) { + onSelectPeer({ number: next }); + } + }; + const status = useUserPresence(peerInfo && 'userId' in peerInfo ? peerInfo.userId : undefined); useEffect(() => { @@ -68,7 +78,7 @@ export const usePeerAutocomplete = (onSelectPeer: (peerInfo: PeerInfo) => void, return { options, - onChangeFilter: setFilter, + onChangeFilter: updateNumberFilter, onChangeValue: (value: string | string[]) => { if (Array.isArray(value)) { return; @@ -94,6 +104,6 @@ export const usePeerAutocomplete = (onSelectPeer: (peerInfo: PeerInfo) => void, }, value: peerInfo && 'userId' in peerInfo ? peerInfo.userId : undefined, filter, - onKeypadPress: (key: string) => setFilter((filter) => filter + key), + onKeypadPress: (key: string) => updateNumberFilter(filter + key), }; }; From 4667ffa5be678e14011e209c623acfeefdca173c Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Mon, 1 Jun 2026 18:47:23 -0300 Subject: [PATCH 3/5] fix(ui-voip): open desktop telephony deeplink reliably on cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On cold start the dial pad opened pre-filled then immediately closed, and the delivered number could be dropped. Two causes: the freshly-initialized media-call instance emits a no-active-call state right after init, which reset the widget from 'new' back to 'closed'; and the deeplink number is delivered consume-once, before the dial pad can act. - useDesktopTelephonyListener: register the handler once at mount and store the delivered number, applying it via a separate effect when the widget is idle. Delivery is consume-once, so registration must not be gated on subsystem readiness — only acting on the number is. - useMediaSession: a no-active-call instance emit no longer tears down an idle 'new' compose widget (e.g. a deeplink-prefilled dial pad); only real call teardown resets it. --- .../useDesktopTelephonyListener.spec.tsx | 77 +++++++++++---- .../providers/useDesktopTelephonyListener.ts | 52 ++++++---- .../src/providers/useMediaSession.spec.tsx | 94 +++++++++++++++++++ .../ui-voip/src/providers/useMediaSession.ts | 18 +++- 4 files changed, 203 insertions(+), 38 deletions(-) create mode 100644 packages/ui-voip/src/providers/useMediaSession.spec.tsx diff --git a/packages/ui-voip/src/providers/useDesktopTelephonyListener.spec.tsx b/packages/ui-voip/src/providers/useDesktopTelephonyListener.spec.tsx index 33b85cd936be1..d57eec66e93e1 100644 --- a/packages/ui-voip/src/providers/useDesktopTelephonyListener.spec.tsx +++ b/packages/ui-voip/src/providers/useDesktopTelephonyListener.spec.tsx @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { useDesktopTelephonyListener } from './useDesktopTelephonyListener'; import type { PeerInfo, SessionState } from '../context/definitions'; @@ -15,14 +15,13 @@ const baseSession = { supportedFeatures: [], } as const; -const emptySession = (state: 'closed' | 'new'): SessionState => ({ ...baseSession, state, callId: undefined }); +const sessionFor = (state: SessionState['state']): SessionState => { + if (state === 'closed' || state === 'new') { + return { ...baseSession, state, callId: undefined }; + } -const callSession = (state: 'calling' | 'ringing' | 'ongoing'): SessionState => ({ - ...baseSession, - state, - callId: 'call-id', - peerInfo: { number: '000' }, -}); + return { ...baseSession, state, callId: 'call-id', peerInfo: { number: '000' } } as SessionState; +}; type TelephonyCallback = (payload: { phoneNumber: string; rawUri: string }) => void; @@ -40,7 +39,10 @@ const setupDesktopBridge = () => { return { onTelephonyCallRequested, - fire: (phoneNumber: string) => registered?.({ phoneNumber, rawUri: `tel:${phoneNumber}` }), + fire: (phoneNumber: string) => + act(() => { + registered?.({ phoneNumber, rawUri: `tel:${phoneNumber}` }); + }), }; }; @@ -52,11 +54,18 @@ const clearDesktopBridge = () => { }); }; -const renderListener = (sessionState: SessionState) => { +const renderListener = (initialState: SessionState['state']) => { const toggleWidget = jest.fn(); const selectPeer = jest.fn(); - renderHook(() => useDesktopTelephonyListener({ sessionState, toggleWidget, selectPeer })); - return { toggleWidget, selectPeer }; + const { rerender } = renderHook( + ({ state }: { state: SessionState['state'] }) => useDesktopTelephonyListener({ sessionState: sessionFor(state), toggleWidget, selectPeer }), + { initialProps: { state: initialState } }, + ); + return { + toggleWidget, + selectPeer, + setState: (state: SessionState['state']) => act(() => rerender({ state })), + }; }; afterEach(() => { @@ -64,15 +73,15 @@ afterEach(() => { jest.clearAllMocks(); }); -it('registers a single telephony callback on mount', () => { +it('registers a single telephony callback once, at mount', () => { const bridge = setupDesktopBridge(); - renderListener(emptySession('closed')); + renderListener('closed'); expect(bridge.onTelephonyCallRequested).toHaveBeenCalledTimes(1); }); it('opens the widget pre-filled when the widget is closed', () => { const bridge = setupDesktopBridge(); - const { toggleWidget, selectPeer } = renderListener(emptySession('closed')); + const { toggleWidget, selectPeer } = renderListener('closed'); bridge.fire('+15551234567'); @@ -82,7 +91,7 @@ it('opens the widget pre-filled when the widget is closed', () => { it('sets the number without re-toggling when the widget is already open and idle', () => { const bridge = setupDesktopBridge(); - const { toggleWidget, selectPeer } = renderListener(emptySession('new')); + const { toggleWidget, selectPeer } = renderListener('new'); bridge.fire('5551234567'); @@ -90,17 +99,47 @@ it('sets the number without re-toggling when the widget is already open and idle expect(toggleWidget).not.toHaveBeenCalled(); }); -it.each(['calling', 'ringing', 'ongoing'] as const)('ignores the request while a call is %s', (state) => { +it.each(['calling', 'ringing', 'ongoing'] as const)('ignores and drops the request while a call is %s', (state) => { const bridge = setupDesktopBridge(); - const { toggleWidget, selectPeer } = renderListener(callSession(state)); + const { toggleWidget, selectPeer, setState } = renderListener(state); bridge.fire('5551234567'); expect(toggleWidget).not.toHaveBeenCalled(); expect(selectPeer).not.toHaveBeenCalled(); + + // Dropped, not parked: returning to idle must not re-open the widget with the stale number. + setState('closed'); + + expect(toggleWidget).not.toHaveBeenCalled(); +}); + +it('applies a number delivered before the widget settles into an idle state', () => { + // Cold-start decouple: the number is delivered (stored) while the session may still be + // transitioning; the open is driven by the effect once the widget reports an idle state. + const bridge = setupDesktopBridge(); + const { toggleWidget } = renderListener('closed'); + + bridge.fire('5551234567'); + + expect(toggleWidget).toHaveBeenCalledWith<[PeerInfo]>({ number: '5551234567' }); +}); + +it('does not re-apply the number after it has been handled', () => { + const bridge = setupDesktopBridge(); + const { toggleWidget, selectPeer, setState } = renderListener('closed'); + + bridge.fire('5551234567'); + expect(toggleWidget).toHaveBeenCalledTimes(1); + + // The widget opens (state -> 'new'); the pending number is already cleared, so no re-apply. + setState('new'); + + expect(toggleWidget).toHaveBeenCalledTimes(1); + expect(selectPeer).not.toHaveBeenCalled(); }); it('does nothing when the desktop bridge is unavailable', () => { clearDesktopBridge(); - expect(() => renderListener(emptySession('closed'))).not.toThrow(); + expect(() => renderListener('closed')).not.toThrow(); }); diff --git a/packages/ui-voip/src/providers/useDesktopTelephonyListener.ts b/packages/ui-voip/src/providers/useDesktopTelephonyListener.ts index ca623d6f53651..9f84e896d344a 100644 --- a/packages/ui-voip/src/providers/useDesktopTelephonyListener.ts +++ b/packages/ui-voip/src/providers/useDesktopTelephonyListener.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useState } from 'react'; import type { PeerInfo, SessionState } from '../context/definitions'; @@ -13,17 +13,26 @@ type TelephonyControls = { * by the Rocket.Chat Desktop app via `window.RocketChatDesktop.onTelephonyCallRequested`, * and pre-populates the media-call widget with the number (the user still starts the call). * - * Routing by current widget state: + * Delivery is consume-once: the Desktop app buffers a cold-start deeplink and hands it over + * synchronously inside the *first* `onTelephonyCallRequested(cb)` call after the buffer is + * ready. So we register exactly once, at mount, against a stable callback that only *stores* + * the number — gating registration on subsystem readiness would lose that one-shot delivery. + * Acting on the number (opening the dial pad) is deferred to a separate effect that waits + * until the widget is idle, decoupling delivery from the volatile media-session state. + * + * Routing once a number is pending: * - `closed` -> open the widget pre-filled with the number * - `new` -> widget already open and idle -> just set the number * - otherwise -> a call is in progress, ignore the request * + * The pending number is cleared as soon as it is handled, so applying it can never loop and + * dismissing the widget afterwards cannot re-open it. + * * The number is forwarded as-is; the Desktop app already strips formatting characters and * no validation is applied on the dial-pad input (see DMV-1 / DMV-6). */ export const useDesktopTelephonyListener = ({ sessionState, toggleWidget, selectPeer }: TelephonyControls) => { - const controlsRef = useRef({ sessionState, toggleWidget, selectPeer }); - controlsRef.current = { sessionState, toggleWidget, selectPeer }; + const [pendingNumber, setPendingNumber] = useState(undefined); useEffect(() => { if (typeof window.RocketChatDesktop?.onTelephonyCallRequested !== 'function') { @@ -31,19 +40,28 @@ export const useDesktopTelephonyListener = ({ sessionState, toggleWidget, select } window.RocketChatDesktop.onTelephonyCallRequested(({ phoneNumber }) => { - const { sessionState, toggleWidget, selectPeer } = controlsRef.current; - const peerInfo: PeerInfo = { number: phoneNumber }; - - switch (sessionState.state) { - case 'closed': - toggleWidget(peerInfo); - break; - case 'new': - selectPeer(peerInfo); - break; - default: - break; - } + setPendingNumber(phoneNumber); }); }, []); + + useEffect(() => { + if (pendingNumber === undefined) { + return; + } + + const peerInfo: PeerInfo = { number: pendingNumber }; + + switch (sessionState.state) { + case 'closed': + toggleWidget(peerInfo); + break; + case 'new': + selectPeer(peerInfo); + break; + default: + break; + } + + setPendingNumber(undefined); + }, [pendingNumber, sessionState.state, toggleWidget, selectPeer]); }; diff --git a/packages/ui-voip/src/providers/useMediaSession.spec.tsx b/packages/ui-voip/src/providers/useMediaSession.spec.tsx new file mode 100644 index 0000000000000..79dc086a45c99 --- /dev/null +++ b/packages/ui-voip/src/providers/useMediaSession.spec.tsx @@ -0,0 +1,94 @@ +import { Emitter } from '@rocket.chat/emitter'; +import type { MediaSignalingSession } from '@rocket.chat/media-signaling'; +import { renderHook, act } from '@testing-library/react'; + +import { useMediaSession } from './useMediaSession'; + +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useUserAvatarPath: () => () => '', + useUserPresence: () => undefined, +})); + +type InstanceEvents = { + sessionStateChange: void; + hiddenCall: void; +}; + +const createFakeInstance = () => { + const emitter = new Emitter(); + let state: unknown = null; + + const instance = { + getState: () => state, + on: (event: keyof InstanceEvents, cb: () => void) => emitter.on(event, cb), + } as unknown as MediaSignalingSession; + + return { + instance, + setState: (next: unknown) => { + state = next; + }, + emitSessionStateChange: () => emitter.emit('sessionStateChange'), + }; +}; + +describe('useMediaSession', () => { + it('initializes with the widget closed', () => { + const fake = createFakeInstance(); + const { result } = renderHook(() => useMediaSession(fake.instance)); + + expect(result.current.sessionState.state).toBe('closed'); + }); + + it('preserves an idle pre-filled widget when the instance reports no active call', () => { + // Reproduces the desktop-deeplink cold-start: the dial pad is opened + pre-filled while the + // media-call instance is still initializing; its autoSync `sessionStateChange` emit (no call + // yet -> getState() === null) must not close the widget. + const fake = createFakeInstance(); + const { result } = renderHook(() => useMediaSession(fake.instance)); + + act(() => { + result.current.toggleWidget({ number: '051999597507' }); + }); + + expect(result.current.sessionState.state).toBe('new'); + + act(() => { + fake.emitSessionStateChange(); + }); + + expect(result.current.sessionState.state).toBe('new'); + expect(result.current.sessionState.peerInfo).toEqual({ number: '051999597507' }); + }); + + it('keeps the widget closed when a no-call emit arrives while idle', () => { + const fake = createFakeInstance(); + const { result } = renderHook(() => useMediaSession(fake.instance)); + + act(() => { + fake.emitSessionStateChange(); + }); + + expect(result.current.sessionState.state).toBe('closed'); + expect(result.current.sessionState.peerInfo).toBeUndefined(); + }); + + it('fully resets the widget when the instance goes away', () => { + const fake = createFakeInstance(); + const { result, rerender } = renderHook(({ instance }: { instance?: MediaSignalingSession }) => useMediaSession(instance), { + initialProps: { instance: fake.instance as MediaSignalingSession | undefined }, + }); + + act(() => { + result.current.toggleWidget({ number: '051999597507' }); + }); + + expect(result.current.sessionState.state).toBe('new'); + + rerender({ instance: undefined }); + + expect(result.current.sessionState.state).toBe('closed'); + expect(result.current.sessionState.peerInfo).toBeUndefined(); + }); +}); diff --git a/packages/ui-voip/src/providers/useMediaSession.ts b/packages/ui-voip/src/providers/useMediaSession.ts index 893bbd06ca5d0..be54c910756bb 100644 --- a/packages/ui-voip/src/providers/useMediaSession.ts +++ b/packages/ui-voip/src/providers/useMediaSession.ts @@ -50,6 +50,9 @@ const reducer = ( | { type: 'reset'; } + | { + type: 'call_cleared'; + } | { type: 'selectPeer'; payload: { peerInfo?: PeerInfo }; @@ -93,6 +96,17 @@ const reducer = ( return defaultSessionInfo; } + // No active call on the instance. Tear down call-derived state, but preserve a user-driven + // idle compose widget ('new') — e.g. a dial pad pre-filled from a desktop telephony deeplink — + // which the instance's autoSync emit would otherwise clobber right after it initializes. + if (action.type === 'call_cleared') { + if (reducerState.state === 'new') { + return reducerState; + } + + return defaultSessionInfo; + } + if (action.type === 'status_updated' && reducerState.peerInfo && 'userId' in reducerState.peerInfo) { return { ...reducerState, peerInfo: { ...reducerState.peerInfo, status: action.payload?.status } }; } @@ -120,7 +134,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS const updateSessionState = () => { const instanceState = instance.getState(); if (!instanceState) { - dispatch({ type: 'reset' }); + dispatch({ type: 'call_cleared' }); return; } @@ -131,7 +145,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS const state = deriveWidgetStateFromCallState(callState, role); if (!state) { - dispatch({ type: 'reset' }); + dispatch({ type: 'call_cleared' }); return; } From 2d663cbf343d051ebaf0e7c085f5c0cd8d2813d9 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Mon, 1 Jun 2026 19:01:22 -0300 Subject: [PATCH 4/5] fix(ui-voip): don't start a call with an empty dialed number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing the dial-pad input re-selects a number peer with an empty number (the input and selected peer are mirrored on purpose). Guard onCall so an empty or whitespace-only number no longer requests media or attempts a SIP call — it is a no-op, consistent with the existing empty-peer case. --- packages/ui-voip/src/providers/MediaCallViewProvider.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx index 4a1ad043c92e2..8ebfb2b8ff9c0 100644 --- a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx +++ b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx @@ -97,6 +97,12 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { return; } + // A number peer can be emptied by clearing the dial-pad input; don't request media or + // attempt a SIP call with no destination. + if ('number' in peerInfo && peerInfo.number.trim() === '') { + return; + } + try { const stream = await requestDevice({ actionType: 'outgoing' }); stopTracks(stream); From 7808b77471af51d13e9af8c855b25ec3309a399f Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Tue, 2 Jun 2026 17:18:58 -0300 Subject: [PATCH 5/5] docs(changeset): reword desktop-api changeset to focus on the enabled feature --- .changeset/telephony-call-requested-desktop-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/telephony-call-requested-desktop-api.md b/.changeset/telephony-call-requested-desktop-api.md index c571a183b9165..bb2339828e808 100644 --- a/.changeset/telephony-call-requested-desktop-api.md +++ b/.changeset/telephony-call-requested-desktop-api.md @@ -2,4 +2,4 @@ '@rocket.chat/desktop-api': minor --- -Add `onTelephonyCallRequested(callback)` to the `IRocketChatDesktop` type definition. Desktop clients can expose this method to forward `tel:`/`callto:` deeplink and global-shortcut phone numbers to the media-call widget. Implemented in Rocket.Chat.Electron#3325. +Adds deeplink and shortcut handling for voice calls by integrating with the desktop app API to allow phone links and global shortcuts triggered outside the app to open the voice widget seamlessly. Related to Rocket.Chat.Electron#3325.