Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0a19388
feat(voip): add MediaCallsAnswerRequest for REST accept/reject
diegolmello Apr 10, 2026
f48c448
feat(voip): migrate accept() and reject() from DDP to REST
diegolmello Apr 10, 2026
9167fa4
feat(voip): remove dead buildMediaCallAnswerParams after REST migration
diegolmello Apr 10, 2026
4b43763
fix(voip): guard against nil API in accept/reject on iOS
diegolmello Apr 10, 2026
ba637f8
fix(voip): add missing RNCallKeep.endCall on user hangup before answer
diegolmello Apr 10, 2026
d9233e7
fix(voip): add missing RNCallKeep.endCall on user hangup before answer
diegolmello Apr 10, 2026
ff477fd
Merge branch 'refactor.ddp-ios' of github.com:RocketChat/Rocket.Chat.…
diegolmello Apr 10, 2026
65b3789
fix(voip): remove duplicate MediaCallsAnswerRequest.swift and add to …
diegolmello Apr 10, 2026
4d13bbb
xcode
diegolmello Apr 10, 2026
da0b23d
merge: resolve conflicts with feat.voip-lib-new (PR 7124)
diegolmello Apr 10, 2026
8d13809
Merge branch 'feat.voip-lib-new' into refactor.ddp-ios
diegolmello Apr 14, 2026
92045ec
Fix build
diegolmello Apr 14, 2026
aa9a875
feat(voip): enhance VoIP handling with deep link normalization and RE…
diegolmello Apr 15, 2026
aec707d
feat(deepLinking): add normalizeDeepLinkingServerHost function and tests
diegolmello Apr 15, 2026
11571bf
chore(voip): address PR review slop in REST state signals refactor
diegolmello Apr 15, 2026
e2e8f8a
test(voip): mock store.getState in MediaCallEvents tests for host gate
diegolmello Apr 15, 2026
70f6239
fix(voip): drop unused async on host-gate test (require-await lint)
diegolmello Apr 15, 2026
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 app/lib/methods/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './getAvatarUrl';
export * from './info';
export * from './isReadOnly';
export * from './media';
export * from './normalizeDeepLinkingServerHost';
export * from './room';
export * from './server';
export * from './isSsl';
Expand Down
24 changes: 24 additions & 0 deletions app/lib/methods/helpers/normalizeDeepLinkingServerHost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { normalizeDeepLinkingServerHost } from './normalizeDeepLinkingServerHost';

describe('normalizeDeepLinkingServerHost', () => {
it('returns empty string for empty input', () => {
expect(normalizeDeepLinkingServerHost('')).toBe('');
});

it('adds https for host without scheme', () => {
expect(normalizeDeepLinkingServerHost('open.rocket.chat')).toBe('https://open.rocket.chat');
});

it('uses http for localhost', () => {
expect(normalizeDeepLinkingServerHost('localhost')).toBe('http://localhost');
expect(normalizeDeepLinkingServerHost('localhost:3000')).toBe('http://localhost:3000');
});

it('upgrades http to https for non-localhost', () => {
expect(normalizeDeepLinkingServerHost('http://example.com')).toBe('https://example.com');
});

it('strips trailing slash', () => {
expect(normalizeDeepLinkingServerHost('https://example.com/')).toBe('https://example.com');
});
});
23 changes: 23 additions & 0 deletions app/lib/methods/helpers/normalizeDeepLinkingServerHost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Normalize a Rocket.Chat server base URL for deep linking and VoIP host comparison.
* Matches the historical behavior in `app/sagas/deepLinking.js` (`handleOpen` host handling).
*/
export function normalizeDeepLinkingServerHost(rawHost: string): string {
let host = rawHost;
if (!host) {
return '';
}
if (!/^(http|https)/.test(host)) {
if (/^localhost(:\d+)?/.test(host)) {
host = `http://${host}`;
} else {
host = `https://${host}`;
}
} else {
host = host.replace('http://', 'https://');
}
if (host.slice(-1) === '/') {
host = host.slice(0, host.length - 1);
}
return host;
}
22 changes: 20 additions & 2 deletions app/lib/services/voip/MediaCallEvents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ jest.mock('../../methods/helpers', () => ({
isIOS: false
}));

const mockServerSelector = jest.fn(() => 'https://workspace-a.example.com');
jest.mock('../../store', () => ({
__esModule: true,
default: {
dispatch: (...args: unknown[]) => mockDispatch(...args)
dispatch: (...args: unknown[]) => mockDispatch(...args),
getState: () => ({ server: { server: mockServerSelector() } })
}
}));

Expand Down Expand Up @@ -51,7 +53,8 @@ jest.mock('react-native-callkeep', () => ({

jest.mock('./MediaSessionInstance', () => ({
mediaSessionInstance: {
endCall: jest.fn()
endCall: jest.fn(),
applyRestStateSignals: jest.fn(() => Promise.resolve())
}
}));

Expand Down Expand Up @@ -127,6 +130,21 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => {
});
});

it('skips deepLinkingOpen and replays REST state signals when host matches active workspace', () => {
const { mediaSessionInstance } = jest.requireMock('./MediaSessionInstance');
mockServerSelector.mockReturnValueOnce('https://workspace-a.example.com');
const payload = buildIncomingPayload({
callId: 'same-ws-call',
host: 'https://workspace-a.example.com'
});

DeviceEventEmitter.emit('VoipAcceptSucceeded', payload);

expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('same-ws-call');
expect(mediaSessionInstance.applyRestStateSignals).toHaveBeenCalledTimes(1);
expect(mockDispatch).not.toHaveBeenCalled();
});

it('does not dispatch or set native id when type is not incoming_call', () => {
DeviceEventEmitter.emit(
'VoipAcceptSucceeded',
Expand Down
29 changes: 26 additions & 3 deletions app/lib/services/voip/MediaCallEvents.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import RNCallKeep from 'react-native-callkeep';
import { DeviceEventEmitter, NativeEventEmitter } from 'react-native';

import { isIOS } from '../../methods/helpers';
import { isIOS, normalizeDeepLinkingServerHost } from '../../methods/helpers';
import store from '../../store';
import { deepLinkingOpen } from '../../../actions/deepLinking';
import { useCallStore } from './useCallStore';
Expand All @@ -17,6 +17,15 @@ const TAG = `[MediaCallEvents][${platform}]`;
const EVENT_VOIP_ACCEPT_FAILED = 'VoipAcceptFailed';
const EVENT_VOIP_ACCEPT_SUCCEEDED = 'VoipAcceptSucceeded';

/** True when normalized incoming host matches the active Redux workspace (no server switch needed). */
function isVoipIncomingHostCurrentWorkspace(incomingHost: string): boolean {
const active = store.getState().server.server;
if (!active || !incomingHost) {
return false;
}
return normalizeDeepLinkingServerHost(incomingHost) === normalizeDeepLinkingServerHost(active);
}

/** Dedupe native emit + stash replay for the same failed accept. */
let lastHandledVoipAcceptFailureCallId: string | null = null;
/** Idempotent warm delivery of native accept success. */
Expand Down Expand Up @@ -56,6 +65,12 @@ function handleVoipAcceptSucceededFromNative(data: VoipPayload) {
console.log(`${TAG} VoipAcceptSucceeded:`, data);
NativeVoipModule.clearInitialEvents();
useCallStore.getState().setNativeAcceptedCallId(data.callId);
if (data.host && isVoipIncomingHostCurrentWorkspace(data.host)) {
mediaSessionInstance.applyRestStateSignals().catch(error => {
console.error(`${TAG} applyRestStateSignals failed:`, error);
});
return;
}
store.dispatch(
deepLinkingOpen({
callId: data.callId,
Expand Down Expand Up @@ -109,8 +124,8 @@ export const setupMediaCallEvents = (): (() => void) => {

// Note: there is intentionally no 'answerCall' listener here.
// VoipService.swift handles accept natively: handleObservedCallChanged detects
// hasConnected = true and calls handleNativeAccept(), which sends the DDP accept
// signal before JS runs. JS receives VoipAcceptSucceeded after success.
// hasConnected = true and calls handleNativeAccept(), which sends the REST accept
// (POST /api/v1/media-calls.answer) before JS runs. JS receives VoipAcceptSucceeded after success.
}

/** Tracks OS-driven hold (competing call) so we only auto-resume that path, not manual hold. */
Expand Down Expand Up @@ -223,6 +238,14 @@ export const getInitialMediaCallEvents = async (): Promise<boolean> => {
if (wasAnswered) {
useCallStore.getState().setNativeAcceptedCallId(initialEvents.callId);

if (initialEvents.host && isVoipIncomingHostCurrentWorkspace(initialEvents.host)) {
mediaSessionInstance.applyRestStateSignals().catch(error => {
console.error(`${TAG} applyRestStateSignals (initial) failed:`, error);
});
console.log(`${TAG} Same workspace as VoIP host; skipped deepLinkingOpen`);
return true;
}

store.dispatch(
deepLinkingOpen({
callId: initialEvents.callId,
Expand Down
Loading
Loading