Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/telephony-call-requested-desktop-api.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/desktop-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
110 changes: 110 additions & 0 deletions packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
24 changes: 22 additions & 2 deletions packages/ui-voip/src/context/usePeerAutocomplete.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the changes to this file really needed? 🤔
Having the number (instead of number and filter) selected seems more inline the way we already pre-fill the widget when opening via DM rooms.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered the chip-only approach, but I think the changes are needed — number peers and userId peers have different display contracts in the current widget:

  • filter == selected number is already the invariant for number peers on develop. When a user dials manually (types a number, picks the first option), onChangeValue selects { number } but doesn't clear the filter — so the input keeps showing the selected number. The sync effect just extends that same invariant to numbers selected externally (deeplink), instead of leaving the input empty only in that one path.
  • DM-room prefill is a userId peer — a different shape. There the chip carries the display (name/avatar) and the input is a search box for finding someone else. For a number peer the input is the dial surface; an empty input with the number only in the chip would make the number un-editable in place (wrong extension, missing prefix), which is a common need for tel: links.
  • updateNumberFilter also fixes a bug that exists on develop today, independent of deeplinks: type 123 → select the first option → type 4 → input shows 1234 but Call dials 123. Keeping the peer in sync with edits closes that stale-dial gap for the manual flow too.

Happy to walk through it if you'd like — but dropping the sync would reintroduce the empty-input inconsistency and the stale-dial edit bug for number peers.

Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
setFilter(peerInfo.number);
}
Comment thread
jeanfbrito marked this conversation as resolved.
}, [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 });
Comment thread
jeanfbrito marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear empty number peers for transfer too

When the transfer modal has a number peer selected, clearing the autocomplete now re-selects { number: '' } here. Fresh evidence beyond the earlier call-flow fix is that TransferModal.confirm still treats any truthy peer with a number property as valid and calls onConfirm('sip', { id: peer.number, ... }), so a user can select/enter a SIP destination, clear the field, and submit an empty transfer target instead of seeing the required-field error. Consider clearing the peer or skipping this re-select when next.trim() is empty.

Useful? React with 👍 / 👎.

}
};

const status = useUserPresence(peerInfo && 'userId' in peerInfo ? peerInfo.userId : undefined);

useEffect(() => {
Expand All @@ -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;
Expand All @@ -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),
};
};
9 changes: 9 additions & 0 deletions packages/ui-voip/src/providers/MediaCallViewProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -43,6 +44,8 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => {

useDesktopNotifications(sessionState);

useDesktopTelephonyListener({ sessionState, toggleWidget, selectPeer });

const setOutputMediaDevice = useSetOutputMediaDevice();
const setInputMediaDevice = useSetInputMediaDevice();

Expand Down Expand Up @@ -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);
Expand Down
145 changes: 145 additions & 0 deletions packages/ui-voip/src/providers/useDesktopTelephonyListener.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 }),

Check failure on line 61 in packages/ui-voip/src/providers/useDesktopTelephonyListener.spec.tsx

View workflow job for this annotation

GitHub Actions / 🔎 Code Check / Code Lint

Replace `·` with `⏎↹↹↹`
{ initialProps: { state: initialState } },
);
return {
toggleWidget,
selectPeer,
setState: (state: SessionState['state']) => act(() => rerender({ state })),

Check failure on line 67 in packages/ui-voip/src/providers/useDesktopTelephonyListener.spec.tsx

View workflow job for this annotation

GitHub Actions / 🔎 Code Check / Code Lint

Avoid wrapping Testing Library util calls in `act`
};
};

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();
});
Loading
Loading