diff --git a/.changeset/telephony-call-requested-desktop-api.md b/.changeset/telephony-call-requested-desktop-api.md new file mode 100644 index 0000000000000..bb2339828e808 --- /dev/null +++ b/.changeset/telephony-call-requested-desktop-api.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/desktop-api': minor +--- + +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. 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..b65ceabefa4f2 100644 --- a/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx +++ b/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx @@ -139,6 +139,116 @@ 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('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 fa7ab2c93f196..e9a79ece4f30b 100644 --- a/packages/ui-voip/src/context/usePeerAutocomplete.ts +++ b/packages/ui-voip/src/context/usePeerAutocomplete.ts @@ -39,6 +39,26 @@ 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]); + + // 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(() => { @@ -58,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; @@ -84,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), }; }; diff --git a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx index bbf30e8a9fdea..8ebfb2b8ff9c0 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(); @@ -94,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); 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..d57eec66e93e1 --- /dev/null +++ b/packages/ui-voip/src/providers/useDesktopTelephonyListener.spec.tsx @@ -0,0 +1,145 @@ +import { act, 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 sessionFor = (state: SessionState['state']): SessionState => { + if (state === 'closed' || state === 'new') { + return { ...baseSession, state, callId: undefined }; + } + + return { ...baseSession, state, callId: 'call-id', peerInfo: { number: '000' } } as SessionState; +}; + +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) => + act(() => { + registered?.({ phoneNumber, rawUri: `tel:${phoneNumber}` }); + }), + }; +}; + +const clearDesktopBridge = () => { + Object.defineProperty(window, 'RocketChatDesktop', { + value: undefined, + writable: true, + configurable: true, + }); +}; + +const renderListener = (initialState: SessionState['state']) => { + const toggleWidget = jest.fn(); + const selectPeer = jest.fn(); + 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(() => { + clearDesktopBridge(); + jest.clearAllMocks(); +}); + +it('registers a single telephony callback once, at mount', () => { + const bridge = setupDesktopBridge(); + 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('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('new'); + + bridge.fire('5551234567'); + + expect(selectPeer).toHaveBeenCalledWith<[PeerInfo]>({ number: '5551234567' }); + expect(toggleWidget).not.toHaveBeenCalled(); +}); + +it.each(['calling', 'ringing', 'ongoing'] as const)('ignores and drops the request while a call is %s', (state) => { + const bridge = setupDesktopBridge(); + 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('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..9f84e896d344a --- /dev/null +++ b/packages/ui-voip/src/providers/useDesktopTelephonyListener.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } 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). + * + * 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 [pendingNumber, setPendingNumber] = useState(undefined); + + useEffect(() => { + if (typeof window.RocketChatDesktop?.onTelephonyCallRequested !== 'function') { + return; + } + + window.RocketChatDesktop.onTelephonyCallRequested(({ phoneNumber }) => { + 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; }