diff --git a/app/containers/MediaCallHeader/MediaCallHeader.stories.tsx b/app/containers/MediaCallHeader/MediaCallHeader.stories.tsx index 4b3e9519fb4..51b00227bce 100644 --- a/app/containers/MediaCallHeader/MediaCallHeader.stories.tsx +++ b/app/containers/MediaCallHeader/MediaCallHeader.stories.tsx @@ -21,7 +21,7 @@ const setStoreState = (overrides: Partial {}, setHeld: () => {}, @@ -44,8 +44,9 @@ const setStoreState = (overrides: Partial ({ + navigateToCallRoom: jest.fn().mockResolvedValue(undefined) +})); const mockCallStartTime = 1713340800000; @@ -21,7 +25,7 @@ const createMockCall = (overrides: Record = {}) => ({ contact: { displayName: 'Bob Burnquist', username: 'bob.burnquist', - sipExtension: '2244' + sipExtension: '' }, setMuted: jest.fn(), setHeld: jest.fn(), @@ -48,8 +52,9 @@ const setStoreState = (overrides: Partial { expect(getByTestId('media-call-header')).toHaveProp('pointerEvents', 'auto'); }); - it('should show alert when content is pressed', () => { + it('should call navigateToCallRoom when content is pressed and navigation is enabled', () => { setStoreState(); const { getByTestId } = render( @@ -218,7 +223,39 @@ describe('MediaCallHeader', () => { ); fireEvent.press(getByTestId('media-call-header-content')); - expect(global.alert).toHaveBeenCalledWith('nav to call room'); + expect(mockNavigateToCallRoom).toHaveBeenCalledTimes(1); + }); + + it('does not call navigateToCallRoom when content is pressed for SIP calls', () => { + setStoreState({ + contact: { + id: 'user-1', + displayName: 'Bob Burnquist', + username: 'bob.burnquist', + sipExtension: '2244' + }, + roomId: 'test-room-rid' + }); + const { getByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('media-call-header-content')); + expect(mockNavigateToCallRoom).not.toHaveBeenCalled(); + }); + + it('does not call navigateToCallRoom when roomId is null', () => { + setStoreState({ roomId: null }); + const { getByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('media-call-header-content')); + expect(mockNavigateToCallRoom).not.toHaveBeenCalled(); }); }); diff --git a/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap b/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap index 689dfc85e09..6fdf09f24f4 100644 --- a/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap +++ b/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap @@ -61,7 +61,7 @@ exports[`Story Snapshots: ActiveCall should match snapshot 1`] = ` collapsable={false} delayLongPress={600} enabled={true} - handlerTag={19} + handlerTag={23} handlerType="NativeViewGestureHandler" hitSlop={ { @@ -132,7 +132,7 @@ exports[`Story Snapshots: ActiveCall should match snapshot 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -244,27 +244,6 @@ exports[`Story Snapshots: ActiveCall should match snapshot 1`] = ` - - 2244 - - - 2244 - - - 2244 - - 2244 - On hold + On hold @@ -1736,7 +1673,7 @@ exports[`Story Snapshots: WithRemoteHeld should match snapshot 1`] = ` collapsable={false} delayLongPress={600} enabled={true} - handlerTag={28} + handlerTag={32} handlerType="NativeViewGestureHandler" hitSlop={ { @@ -1867,7 +1804,7 @@ exports[`Story Snapshots: WithRemoteMuted should match snapshot 1`] = ` collapsable={false} delayLongPress={600} enabled={true} - handlerTag={29} + handlerTag={33} handlerType="NativeViewGestureHandler" hitSlop={ { @@ -1938,7 +1875,7 @@ exports[`Story Snapshots: WithRemoteMuted should match snapshot 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -2069,7 +2006,7 @@ exports[`Story Snapshots: WithRemoteMuted should match snapshot 1`] = ` } testID="call-view-header-subtitle" > - 2244 - Muted + Muted @@ -2094,7 +2031,7 @@ exports[`Story Snapshots: WithRemoteMuted should match snapshot 1`] = ` collapsable={false} delayLongPress={600} enabled={true} - handlerTag={30} + handlerTag={34} handlerType="NativeViewGestureHandler" hitSlop={ { diff --git a/app/containers/MediaCallHeader/components/Content.tsx b/app/containers/MediaCallHeader/components/Content.tsx index d6d3c079d7f..2072825ab8f 100644 --- a/app/containers/MediaCallHeader/components/Content.tsx +++ b/app/containers/MediaCallHeader/components/Content.tsx @@ -1,5 +1,7 @@ import { Pressable, StyleSheet, View } from 'react-native'; +import { navigateToCallRoom } from '../../../lib/services/voip/navigateToCallRoom'; +import { useCallStore } from '../../../lib/services/voip/useCallStore'; import Title from './Title'; import Subtitle from './Subtitle'; @@ -16,11 +18,24 @@ const styles = StyleSheet.create({ } }); -export const Content = () => ( - alert('nav to call room')} style={styles.button}> - - - <Subtitle /> - </View> - </Pressable> -); +export const Content = () => { + const roomId = useCallStore(state => state.roomId); + const contact = useCallStore(state => state.contact); + const contentDisabled = Boolean(contact.sipExtension) || roomId == null; + const pressableStyle = contentDisabled ? [styles.button, { opacity: 0.5 }] : styles.button; + + return ( + <Pressable + testID='media-call-header-content' + disabled={contentDisabled} + onPress={() => { + navigateToCallRoom().catch(() => undefined); + }} + style={pressableStyle}> + <View style={styles.container}> + <Title /> + <Subtitle /> + </View> + </Pressable> + ); +}; diff --git a/app/lib/database/services/Subscription.test.ts b/app/lib/database/services/Subscription.test.ts new file mode 100644 index 00000000000..37844aac6f7 --- /dev/null +++ b/app/lib/database/services/Subscription.test.ts @@ -0,0 +1,53 @@ +import database from '../index'; +import { SUBSCRIPTIONS_TABLE } from '../model/Subscription'; +import { type TSubscriptionModel } from '../../../definitions'; +import { getDMSubscriptionByUsername } from './Subscription'; + +jest.mock('../index', () => ({ + __esModule: true, + default: { + active: { + get: jest.fn() + } + } +})); + +const mockGet = database.active.get as jest.Mock; + +describe('getDMSubscriptionByUsername', () => { + let mockFetch: jest.Mock; + + beforeEach(() => { + mockFetch = jest.fn(); + mockGet.mockReturnValue({ + query: jest.fn(() => ({ fetch: mockFetch })) + }); + }); + + it('returns subscription when DM exists', async () => { + const sub = { id: 'sub-1', name: 'alice', t: 'd', rid: 'rid-1' } as unknown as TSubscriptionModel; + mockFetch.mockResolvedValue([sub]); + + const result = await getDMSubscriptionByUsername('alice'); + + expect(result).toBe(sub); + expect(mockGet).toHaveBeenCalledWith(SUBSCRIPTIONS_TABLE); + }); + + it('returns null when no DM exists', async () => { + mockFetch.mockResolvedValue([]); + + const result = await getDMSubscriptionByUsername('nobody'); + + expect(result).toBeNull(); + }); + + it('returns null when username is empty and does not query the database', async () => { + mockGet.mockClear(); + + const result = await getDMSubscriptionByUsername(''); + + expect(result).toBeNull(); + expect(mockGet).not.toHaveBeenCalled(); + }); +}); diff --git a/app/lib/database/services/Subscription.ts b/app/lib/database/services/Subscription.ts index 812822e3682..df7ccd82a6d 100644 --- a/app/lib/database/services/Subscription.ts +++ b/app/lib/database/services/Subscription.ts @@ -1,3 +1,5 @@ +import { Q } from '@nozbe/watermelondb'; + import database from '..'; import { type TSubscriptionModel } from '../../../definitions'; import { type TAppDatabase } from '../interfaces'; @@ -5,6 +7,20 @@ import { SUBSCRIPTIONS_TABLE } from '../model/Subscription'; const getCollection = (db: TAppDatabase) => db.get(SUBSCRIPTIONS_TABLE); +/** + * Returns the WatermelonDB direct-message subscription for a username (`name` + type `d`), or null. + * Skips the query when `username` is falsy. + */ +export const getDMSubscriptionByUsername = async (username: string): Promise<TSubscriptionModel | null> => { + if (!username) { + return null; + } + const db = database.active; + const subCollection = getCollection(db); + const rows = await subCollection.query(Q.where('name', username), Q.where('t', 'd'), Q.take(1)).fetch(); + return rows[0] ?? null; +}; + export const getSubscriptionByRoomId = async (rid: string): Promise<TSubscriptionModel | null> => { const db = database.active; const subCollection = getCollection(db); diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index 4f5dcc513b9..288a2d2eded 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -1,19 +1,36 @@ import type { IClientMediaCall } from '@rocket.chat/media-signaling'; import RNCallKeep from 'react-native-callkeep'; +import { waitFor } from '@testing-library/react-native'; import type { IDDPMessage } from '../../../definitions/IDDPMessage'; import Navigation from '../../navigation/appNavigation'; +import { getDMSubscriptionByUsername } from '../../database/services/Subscription'; +import { getUidDirectMessage } from '../../methods/helpers/helpers'; import { mediaSessionStore } from './MediaSessionStore'; import { mediaSessionInstance } from './MediaSessionInstance'; +jest.mock('../../database/services/Subscription', () => ({ + getDMSubscriptionByUsername: jest.fn() +})); + +jest.mock('../../methods/helpers/helpers', () => ({ + getUidDirectMessage: jest.fn(() => 'other-user-id') +})); + +const mockGetDMSubscriptionByUsername = jest.mocked(getDMSubscriptionByUsername); +const mockGetUidDirectMessage = jest.mocked(getUidDirectMessage); + const mockCallStoreReset = jest.fn(); +const mockSetRoomId = jest.fn(); const mockUseCallStoreGetState = jest.fn(() => ({ reset: mockCallStoreReset, setCall: jest.fn(), + setRoomId: mockSetRoomId, resetNativeCallId: jest.fn(), call: null as unknown, callId: null as string | null, - nativeAcceptedCallId: null as string | null + nativeAcceptedCallId: null as string | null, + roomId: null as string | null })); jest.mock('./useCallStore', () => ({ @@ -59,6 +76,7 @@ jest.mock('react-native-callkeep', () => ({ setAvailable: jest.fn() } })); + jest.mock('react-native-device-info', () => ({ getUniqueId: jest.fn(() => 'test-device-id'), getUniqueIdSync: jest.fn(() => 'test-device-id') @@ -158,13 +176,17 @@ describe('MediaSessionInstance', () => { beforeEach(() => { jest.clearAllMocks(); createdSessions.length = 0; + mockGetUidDirectMessage.mockReturnValue('other-user-id'); + mockGetDMSubscriptionByUsername.mockResolvedValue(null); mockUseCallStoreGetState.mockReturnValue({ reset: mockCallStoreReset, setCall: jest.fn(), + setRoomId: mockSetRoomId, resetNativeCallId: jest.fn(), call: null, callId: null, - nativeAcceptedCallId: null + nativeAcceptedCallId: null, + roomId: null }); mediaSessionInstance.reset(); }); @@ -247,10 +269,12 @@ describe('MediaSessionInstance', () => { mockUseCallStoreGetState.mockReturnValue({ reset: mockCallStoreReset, setCall: mockSetCall, + setRoomId: mockSetRoomId, resetNativeCallId: jest.fn(), call: { callId: 'active-a' } as IClientMediaCall, callId: 'active-a', - nativeAcceptedCallId: null + nativeAcceptedCallId: null, + roomId: null }); mediaSessionInstance.init('user-1'); const incoming = buildClientMediaCall({ callId: 'incoming-b', role: 'callee' }); @@ -263,10 +287,12 @@ describe('MediaSessionInstance', () => { mockUseCallStoreGetState.mockReturnValue({ reset: mockCallStoreReset, setCall: jest.fn(), + setRoomId: mockSetRoomId, resetNativeCallId: jest.fn(), call: { callId: 'active-a' } as IClientMediaCall, callId: 'active-a', - nativeAcceptedCallId: 'native-other' + nativeAcceptedCallId: 'native-other', + roomId: null }); mediaSessionInstance.init('user-1'); const incoming = buildClientMediaCall({ callId: 'incoming-b', role: 'callee' }); @@ -279,10 +305,12 @@ describe('MediaSessionInstance', () => { mockUseCallStoreGetState.mockReturnValue({ reset: mockCallStoreReset, setCall: jest.fn(), + setRoomId: mockSetRoomId, resetNativeCallId: jest.fn(), call: null, callId: null, - nativeAcceptedCallId: 'same-id' + nativeAcceptedCallId: 'same-id', + roomId: null }); mediaSessionInstance.init('user-1'); const incoming = buildClientMediaCall({ callId: 'same-id', role: 'callee' }); @@ -296,10 +324,12 @@ describe('MediaSessionInstance', () => { mockUseCallStoreGetState.mockReturnValue({ reset: mockCallStoreReset, setCall: mockSetCall, + setRoomId: mockSetRoomId, resetNativeCallId: jest.fn(), call: null, callId: null, - nativeAcceptedCallId: null + nativeAcceptedCallId: null, + roomId: null }); mediaSessionInstance.init('user-1'); const outgoing = buildClientMediaCall({ callId: 'out-c', role: 'caller' }); @@ -339,10 +369,12 @@ describe('MediaSessionInstance', () => { mockUseCallStoreGetState.mockReturnValue({ reset: mockCallStoreReset, setCall: jest.fn(), + setRoomId: mockSetRoomId, resetNativeCallId: jest.fn(), call: null, callId: null, - nativeAcceptedCallId: 'from-signal' + nativeAcceptedCallId: 'from-signal', + roomId: null }); mediaSessionInstance.init('user-1'); const streamHandler = getStreamNotifyHandler(); @@ -370,10 +402,12 @@ describe('MediaSessionInstance', () => { mockUseCallStoreGetState.mockReturnValue({ reset: mockCallStoreReset, setCall: jest.fn(), + setRoomId: mockSetRoomId, resetNativeCallId: jest.fn(), call: null, callId: null, - nativeAcceptedCallId: 'sticky-only' + nativeAcceptedCallId: 'sticky-only', + roomId: null }); mediaSessionInstance.init('user-1'); const streamHandler = getStreamNotifyHandler(); @@ -401,10 +435,12 @@ describe('MediaSessionInstance', () => { mockUseCallStoreGetState.mockReturnValue({ reset: mockCallStoreReset, setCall: jest.fn(), + setRoomId: mockSetRoomId, resetNativeCallId: jest.fn(), call: { callId: 'from-signal' } as any, callId: 'from-signal', - nativeAcceptedCallId: 'from-signal' + nativeAcceptedCallId: 'from-signal', + roomId: null }); mediaSessionInstance.init('user-1'); const streamHandler = getStreamNotifyHandler(); @@ -438,4 +474,132 @@ describe('MediaSessionInstance', () => { expect(session.startCall).toHaveBeenCalledWith('user', 'peer-1'); }); }); + + describe('roomId population', () => { + it('startCallByRoom sets roomId before startCall', () => { + mediaSessionInstance.init('user-1'); + const session = createdSessions[0]; + const order: string[] = []; + mockSetRoomId.mockImplementationOnce(() => { + order.push('setRoomId'); + }); + session.startCall.mockImplementationOnce(() => { + order.push('startCall'); + }); + + mediaSessionInstance.startCallByRoom({ rid: 'rid-dm', t: 'd', uids: ['a', 'b'] } as any); + + expect(mockSetRoomId).toHaveBeenCalledWith('rid-dm'); + expect(session.startCall).toHaveBeenCalledWith('user', 'other-user-id'); + expect(order).toEqual(['setRoomId', 'startCall']); + }); + + it('newCall caller triggers DM lookup when roomId is still null', async () => { + mockGetDMSubscriptionByUsername.mockResolvedValue({ rid: 'from-db' } as any); + mediaSessionInstance.init('user-1'); + const session = createdSessions[0]; + const newCallHandler = session.on.mock.calls.find((c: string[]) => c[0] === 'newCall')?.[1] as (p: { + call: IClientMediaCall; + }) => void; + + newCallHandler({ + call: { + hidden: false, + role: 'caller', + callId: 'c1', + contact: { username: 'alice', sipExtension: '' }, + emitter: { on: jest.fn(), off: jest.fn() } + } as unknown as IClientMediaCall + }); + + await waitFor(() => expect(mockGetDMSubscriptionByUsername).toHaveBeenCalledWith('alice')); + expect(mockSetRoomId).toHaveBeenCalledWith('from-db'); + }); + + it('newCall caller skips DM lookup when roomId already set', async () => { + mediaSessionInstance.init('user-1'); + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: jest.fn(), + setRoomId: mockSetRoomId, + resetNativeCallId: jest.fn(), + call: null, + callId: null, + nativeAcceptedCallId: null, + roomId: 'preset-rid' + }); + const session = createdSessions[0]; + const newCallHandler = session.on.mock.calls.find((c: string[]) => c[0] === 'newCall')?.[1] as (p: { + call: IClientMediaCall; + }) => void; + + newCallHandler({ + call: { + hidden: false, + role: 'caller', + callId: 'c1', + contact: { username: 'alice', sipExtension: '' }, + emitter: { on: jest.fn(), off: jest.fn() } + } as unknown as IClientMediaCall + }); + + await Promise.resolve(); + expect(mockGetDMSubscriptionByUsername).not.toHaveBeenCalled(); + }); + + it('newCall caller skips DM lookup for SIP contact', async () => { + mediaSessionInstance.init('user-1'); + const session = createdSessions[0]; + const newCallHandler = session.on.mock.calls.find((c: string[]) => c[0] === 'newCall')?.[1] as (p: { + call: IClientMediaCall; + }) => void; + + newCallHandler({ + call: { + hidden: false, + role: 'caller', + callId: 'c1', + contact: { username: 'alice', sipExtension: '100' }, + emitter: { on: jest.fn(), off: jest.fn() } + } as unknown as IClientMediaCall + }); + + await Promise.resolve(); + expect(mockGetDMSubscriptionByUsername).not.toHaveBeenCalled(); + }); + + it('answerCall resolves roomId from DM for non-SIP callee', async () => { + mockGetDMSubscriptionByUsername.mockResolvedValue({ rid: 'dm-rid' } as any); + mediaSessionInstance.init('user-1'); + const session = createdSessions[0]; + const mainCall = { + callId: 'call-ans', + accept: jest.fn().mockResolvedValue(undefined), + contact: { username: 'bob', sipExtension: '' } + }; + session.getMainCall.mockReturnValue(mainCall); + + await mediaSessionInstance.answerCall('call-ans'); + + await waitFor(() => expect(mockSetRoomId).toHaveBeenCalledWith('dm-rid')); + expect(mockGetDMSubscriptionByUsername).toHaveBeenCalledWith('bob'); + }); + + it('answerCall skips DM lookup for SIP contact', async () => { + mediaSessionInstance.init('user-1'); + const session = createdSessions[0]; + const mainCall = { + callId: 'call-sip', + accept: jest.fn().mockResolvedValue(undefined), + contact: { username: 'bob', sipExtension: 'ext' } + }; + session.getMainCall.mockReturnValue(mainCall); + + await mediaSessionInstance.answerCall('call-sip'); + + await Promise.resolve(); + expect(mockGetDMSubscriptionByUsername).not.toHaveBeenCalled(); + expect(mockSetRoomId).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 613092f27d5..7175bf4da1b 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -19,6 +19,7 @@ import { parseStringToIceServers } from './parseStringToIceServers'; import type { IceServer } from '../../../definitions/Voip'; import type { IDDPMessage } from '../../../definitions/IDDPMessage'; import type { ISubscription, TSubscriptionModel } from '../../../definitions'; +import { getDMSubscriptionByUsername } from '../../database/services/Subscription'; import { getUidDirectMessage } from '../../methods/helpers/helpers'; import { requestPhoneStatePermission } from '../../methods/voipPhoneStatePermission'; @@ -93,6 +94,11 @@ class MediaSessionInstance { if (call.role === 'caller') { useCallStore.getState().setCall(call); Navigation.navigate('CallView'); + if (useCallStore.getState().roomId == null) { + this.resolveRoomIdFromContact(call.contact).catch(error => { + console.error('[VoIP] Error resolving room id from contact (newCall):', error); + }); + } } call.emitter.on('ended', () => { @@ -120,6 +126,9 @@ class MediaSessionInstance { RNCallKeep.setCurrentCallActive(callId); useCallStore.getState().setCall(mainCall); Navigation.navigate('CallView'); + this.resolveRoomIdFromContact(mainCall.contact).catch(error => { + console.error('[VoIP] Error resolving room id from contact (answerCall):', error); + }); } else { RNCallKeep.endCall(callId); const st = useCallStore.getState(); @@ -131,6 +140,7 @@ class MediaSessionInstance { }; public startCallByRoom = (room: TSubscriptionModel | ISubscription) => { + useCallStore.getState().setRoomId(room.rid ?? null); const otherUserId = getUidDirectMessage(room); if (otherUserId) { this.startCall(otherUserId, 'user'); @@ -160,6 +170,20 @@ class MediaSessionInstance { useCallStore.getState().reset(); }; + private async resolveRoomIdFromContact(contact: IClientMediaCall['contact']): Promise<void> { + if (contact.sipExtension) { + return; + } + const { username } = contact; + if (!username) { + return; + } + const sub = await getDMSubscriptionByUsername(username); + if (sub) { + useCallStore.getState().setRoomId(sub.rid); + } + } + private getIceServers() { const iceServers = store.getState().settings.VoIP_TeamCollab_Ice_Servers as any; return parseStringToIceServers(iceServers); diff --git a/app/lib/services/voip/navigateToCallRoom.test.ts b/app/lib/services/voip/navigateToCallRoom.test.ts new file mode 100644 index 00000000000..3f5a1bb8563 --- /dev/null +++ b/app/lib/services/voip/navigateToCallRoom.test.ts @@ -0,0 +1,218 @@ +import { goRoom } from '../../methods/helpers/goRoom'; +import Navigation from '../../navigation/appNavigation'; +import { store } from '../../store/auxStore'; +import { useCallStore } from './useCallStore'; +import { navigateToCallRoom } from './navigateToCallRoom'; +import { SubscriptionType } from '../../../definitions'; + +jest.mock('./useCallStore', () => ({ + useCallStore: { + getState: jest.fn() + } +})); + +jest.mock('../../methods/helpers/goRoom', () => ({ + goRoom: jest.fn().mockResolvedValue(undefined) +})); + +jest.mock('../../store/auxStore', () => ({ + store: { + getState: jest.fn() + } +})); + +jest.mock('../../navigation/appNavigation', () => ({ + __esModule: true, + default: { + getCurrentRoute: jest.fn(), + navigate: jest.fn() + } +})); + +const mockGetState = jest.mocked(useCallStore.getState); +const mockGoRoom = jest.mocked(goRoom); +const mockStoreGetState = jest.mocked(store.getState); +const mockNavigation = jest.mocked(Navigation); + +type CallStoreSnapshot = ReturnType<typeof useCallStore.getState>; + +/** Partial mock: tests only need fields read by `navigateToCallRoom`. */ +function mockCallStoreState( + snapshot: Pick<CallStoreSnapshot, 'roomId' | 'contact' | 'focused' | 'toggleFocus'> +): CallStoreSnapshot { + return snapshot as unknown as CallStoreSnapshot; +} + +describe('navigateToCallRoom', () => { + const toggleFocus = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockStoreGetState.mockReturnValue({ app: { isMasterDetail: true } } as ReturnType<typeof store.getState>); + mockNavigation.getCurrentRoute.mockReturnValue({ name: 'RoomsListView' } as any); + }); + + it('does not navigate when roomId is null', async () => { + mockGetState.mockReturnValue( + mockCallStoreState({ + roomId: null, + contact: { username: 'u', sipExtension: '' }, + focused: false, + toggleFocus + }) + ); + + await navigateToCallRoom(); + + expect(mockGoRoom).not.toHaveBeenCalled(); + expect(toggleFocus).not.toHaveBeenCalled(); + expect(mockNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('does not navigate for SIP contact', async () => { + mockGetState.mockReturnValue( + mockCallStoreState({ + roomId: 'rid-1', + contact: { username: 'u', sipExtension: '100' }, + focused: false, + toggleFocus + }) + ); + + await navigateToCallRoom(); + + expect(mockGoRoom).not.toHaveBeenCalled(); + expect(toggleFocus).not.toHaveBeenCalled(); + expect(mockNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('does not navigate when username is missing', async () => { + mockGetState.mockReturnValue( + mockCallStoreState({ + roomId: 'rid-1', + contact: { username: undefined, sipExtension: '' }, + focused: false, + toggleFocus + }) + ); + + await navigateToCallRoom(); + + expect(mockGoRoom).not.toHaveBeenCalled(); + expect(toggleFocus).not.toHaveBeenCalled(); + expect(mockNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('minimizes first when CallView is focused then navigates', async () => { + mockGetState.mockReturnValue( + mockCallStoreState({ + roomId: 'rid-1', + contact: { username: 'alice', sipExtension: '' }, + focused: true, + toggleFocus + }) + ); + + await navigateToCallRoom(); + + expect(toggleFocus).toHaveBeenCalledTimes(1); + expect(mockGoRoom).toHaveBeenCalledWith({ + item: { rid: 'rid-1', name: 'alice', t: SubscriptionType.DIRECT }, + isMasterDetail: true + }); + expect(toggleFocus.mock.invocationCallOrder[0]).toBeLessThan(mockGoRoom.mock.invocationCallOrder[0]); + }); + + it('navigates without toggleFocus when already minimized', async () => { + mockGetState.mockReturnValue( + mockCallStoreState({ + roomId: 'rid-1', + contact: { username: 'alice', sipExtension: '' }, + focused: false, + toggleFocus + }) + ); + + await navigateToCallRoom(); + + expect(toggleFocus).not.toHaveBeenCalled(); + expect(mockGoRoom).toHaveBeenCalledWith({ + item: { rid: 'rid-1', name: 'alice', t: SubscriptionType.DIRECT }, + isMasterDetail: true + }); + }); + + it('navigates to ChatsStackNavigator first when on ProfileView', async () => { + mockNavigation.getCurrentRoute.mockReturnValue({ name: 'ProfileView' } as any); + mockGetState.mockReturnValue( + mockCallStoreState({ + roomId: 'rid-1', + contact: { username: 'alice', sipExtension: '' }, + focused: false, + toggleFocus + }) + ); + + await navigateToCallRoom(); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('ChatsStackNavigator'); + expect(mockGoRoom).toHaveBeenCalledWith({ + item: { rid: 'rid-1', name: 'alice', t: SubscriptionType.DIRECT }, + isMasterDetail: true + }); + expect(mockNavigation.navigate.mock.invocationCallOrder[0]).toBeLessThan(mockGoRoom.mock.invocationCallOrder[0]); + }); + + it('navigates to ChatsStackNavigator first when on AccessibilityAndAppearanceView', async () => { + mockNavigation.getCurrentRoute.mockReturnValue({ name: 'AccessibilityAndAppearanceView' } as any); + mockGetState.mockReturnValue( + mockCallStoreState({ + roomId: 'rid-1', + contact: { username: 'alice', sipExtension: '' }, + focused: false, + toggleFocus + }) + ); + + await navigateToCallRoom(); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('ChatsStackNavigator'); + expect(mockGoRoom).toHaveBeenCalled(); + expect(mockNavigation.navigate.mock.invocationCallOrder[0]).toBeLessThan(mockGoRoom.mock.invocationCallOrder[0]); + }); + + it('navigates to ChatsStackNavigator first when on SettingsView', async () => { + mockNavigation.getCurrentRoute.mockReturnValue({ name: 'SettingsView' } as any); + mockGetState.mockReturnValue( + mockCallStoreState({ + roomId: 'rid-1', + contact: { username: 'alice', sipExtension: '' }, + focused: false, + toggleFocus + }) + ); + + await navigateToCallRoom(); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('ChatsStackNavigator'); + expect(mockGoRoom).toHaveBeenCalled(); + expect(mockNavigation.navigate.mock.invocationCallOrder[0]).toBeLessThan(mockGoRoom.mock.invocationCallOrder[0]); + }); + + it('does not navigate to ChatsStackNavigator when already on RoomView', async () => { + mockNavigation.getCurrentRoute.mockReturnValue({ name: 'RoomView' } as any); + mockGetState.mockReturnValue( + mockCallStoreState({ + roomId: 'rid-1', + contact: { username: 'alice', sipExtension: '' }, + focused: false, + toggleFocus + }) + ); + + await navigateToCallRoom(); + + expect(mockNavigation.navigate).not.toHaveBeenCalled(); + expect(mockGoRoom).toHaveBeenCalled(); + }); +}); diff --git a/app/lib/services/voip/navigateToCallRoom.ts b/app/lib/services/voip/navigateToCallRoom.ts new file mode 100644 index 00000000000..ff158b14bd0 --- /dev/null +++ b/app/lib/services/voip/navigateToCallRoom.ts @@ -0,0 +1,46 @@ +import { SubscriptionType } from '../../../definitions'; +import { goRoom } from '../../methods/helpers/goRoom'; +import Navigation from '../../navigation/appNavigation'; +import { store } from '../../store/auxStore'; +import { useCallStore } from './useCallStore'; + +/** + * From the VoIP UI, open the DM for the active call: minimizes CallView when it is focused, then navigates. + * No-ops for SIP calls or when room id or username is missing. + */ +export async function navigateToCallRoom(): Promise<void> { + const { roomId, contact, focused, toggleFocus } = useCallStore.getState(); + + if (!roomId || contact.sipExtension) { + return; + } + + const { username } = contact; + if (!username) { + return; + } + + if (focused) { + toggleFocus(); + } + + const { + app: { isMasterDetail } + } = store.getState(); + + // If we're not in the chats navigator (e.g., in Profile/Settings/Accessibility screens), + // navigate to ChatsStackNavigator first to ensure goRoom works correctly + const currentRoute = Navigation.getCurrentRoute() as any; + if (currentRoute?.name !== 'RoomsListView' && currentRoute?.name !== 'RoomView') { + Navigation.navigate('ChatsStackNavigator'); + } + + await goRoom({ + item: { + rid: roomId, + name: username, + t: SubscriptionType.DIRECT + }, + isMasterDetail + }); +} diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts index 8b137825b8b..706c79dd541 100644 --- a/app/lib/services/voip/useCallStore.test.ts +++ b/app/lib/services/voip/useCallStore.test.ts @@ -13,12 +13,6 @@ jest.mock('../../../containers/ActionSheet', () => ({ jest.mock('react-native-callkeep', () => ({})); -jest.mock('react-native-incall-manager', () => ({ - start: jest.fn(), - stop: jest.fn(), - setForceSpeakerphoneOn: jest.fn() -})); - function createMockCall(callId: string) { const listeners: Record<string, Set<(...args: unknown[]) => void>> = {}; const emitter = { @@ -130,6 +124,24 @@ describe('useCallStore controlsVisible', () => { }); }); +describe('useCallStore roomId', () => { + beforeEach(() => { + useCallStore.getState().resetNativeCallId(); + useCallStore.getState().reset(); + }); + + it('setRoomId sets the value', () => { + useCallStore.getState().setRoomId('room-rid-abc'); + expect(useCallStore.getState().roomId).toBe('room-rid-abc'); + }); + + it('reset clears roomId to null', () => { + useCallStore.getState().setRoomId('room-rid-abc'); + useCallStore.getState().reset(); + expect(useCallStore.getState().roomId).toBeNull(); + }); +}); + describe('useCallStore native accepted + stale timer', () => { beforeEach(() => { jest.useFakeTimers(); diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 28b311769ff..e4763234c64 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -71,6 +71,9 @@ interface CallStoreState { controlsVisible: boolean; dialpadValue: string; + /** DM room id for the current call; cleared on `reset()`. */ + roomId: string | null; + // Contact info contact: CallContact; } @@ -91,6 +94,7 @@ interface CallStoreActions { /** Clears UI/call fields but keeps nativeAcceptedCallId. Restarts the 15s timer (media init calls reset and clears the old timer first). */ reset: () => void; setDialpadValue: (value: string) => void; + setRoomId: (roomId: string | null) => void; } export type CallStore = CallStoreState & CallStoreActions; @@ -109,7 +113,8 @@ const initialState: CallStoreState = { contact: {}, focused: true, controlsVisible: true, - dialpadValue: '' + dialpadValue: '', + roomId: null }; export const useCallStore = create<CallStore>((set, get) => ({ @@ -258,6 +263,10 @@ export const useCallStore = create<CallStore>((set, get) => ({ set({ dialpadValue: newValue }); }, + setRoomId: (roomId: string | null) => { + set({ roomId }); + }, + endCall: () => { const { call, callId, nativeAcceptedCallId } = get(); // UUID for the native call UI layer (react-native-callkeep on iOS and Android). diff --git a/app/views/CallView/__snapshots__/index.test.tsx.snap b/app/views/CallView/__snapshots__/index.test.tsx.snap index 213fd554fd5..6518f575509 100644 --- a/app/views/CallView/__snapshots__/index.test.tsx.snap +++ b/app/views/CallView/__snapshots__/index.test.tsx.snap @@ -511,7 +511,7 @@ exports[`Story Snapshots: ConnectedCall should match snapshot 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": false, + "disabled": true, "expanded": undefined, "selected": undefined, } @@ -546,7 +546,9 @@ exports[`Story Snapshots: ConnectedCall should match snapshot 1`] = ` "marginBottom": 8, "width": 64, }, - false, + { + "opacity": 0.5, + }, { "backgroundColor": "#E4E7EA", }, @@ -1320,7 +1322,7 @@ exports[`Story Snapshots: ConnectingCall should match snapshot 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": false, + "disabled": true, "expanded": undefined, "selected": undefined, } @@ -1355,7 +1357,9 @@ exports[`Story Snapshots: ConnectingCall should match snapshot 1`] = ` "marginBottom": 8, "width": 64, }, - false, + { + "opacity": 0.5, + }, { "backgroundColor": "#E4E7EA", }, @@ -2125,7 +2129,7 @@ exports[`Story Snapshots: MutedAndOnHold should match snapshot 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": false, + "disabled": true, "expanded": undefined, "selected": undefined, } @@ -2160,7 +2164,9 @@ exports[`Story Snapshots: MutedAndOnHold should match snapshot 1`] = ` "marginBottom": 8, "width": 64, }, - false, + { + "opacity": 0.5, + }, { "backgroundColor": "#E4E7EA", }, @@ -2928,7 +2934,7 @@ exports[`Story Snapshots: MutedCall should match snapshot 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": false, + "disabled": true, "expanded": undefined, "selected": undefined, } @@ -2963,7 +2969,9 @@ exports[`Story Snapshots: MutedCall should match snapshot 1`] = ` "marginBottom": 8, "width": 64, }, - false, + { + "opacity": 0.5, + }, { "backgroundColor": "#E4E7EA", }, @@ -3731,7 +3739,7 @@ exports[`Story Snapshots: OnHoldCall should match snapshot 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": false, + "disabled": true, "expanded": undefined, "selected": undefined, } @@ -3766,7 +3774,9 @@ exports[`Story Snapshots: OnHoldCall should match snapshot 1`] = ` "marginBottom": 8, "width": 64, }, - false, + { + "opacity": 0.5, + }, { "backgroundColor": "#E4E7EA", }, @@ -4534,7 +4544,7 @@ exports[`Story Snapshots: SpeakerOn should match snapshot 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": false, + "disabled": true, "expanded": undefined, "selected": undefined, } @@ -4569,7 +4579,9 @@ exports[`Story Snapshots: SpeakerOn should match snapshot 1`] = ` "marginBottom": 8, "width": 64, }, - false, + { + "opacity": 0.5, + }, { "backgroundColor": "#E4E7EA", }, diff --git a/app/views/CallView/components/CallButtons.test.tsx b/app/views/CallView/components/CallButtons.test.tsx index 0cb5b5b4ccd..1cc888ba70e 100644 --- a/app/views/CallView/components/CallButtons.test.tsx +++ b/app/views/CallView/components/CallButtons.test.tsx @@ -1,34 +1,22 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { fireEvent, render } from '@testing-library/react-native'; import { Provider } from 'react-redux'; -import { CallButtons } from './CallButtons'; -import { useCallStore } from '../../../lib/services/voip/useCallStore'; import { mockedStore } from '../../../reducers/mockedStore'; +import { useCallStore } from '../../../lib/services/voip/useCallStore'; +import { navigateToCallRoom } from '../../../lib/services/voip/navigateToCallRoom'; +import { CallButtons } from './CallButtons'; + +jest.mock('../../../lib/services/voip/navigateToCallRoom', () => ({ + navigateToCallRoom: jest.fn().mockResolvedValue(undefined) +})); -const mockShowActionSheetRef = jest.fn(); jest.mock('../../../containers/ActionSheet', () => ({ ...jest.requireActual('../../../containers/ActionSheet'), - showActionSheetRef: (options: any) => mockShowActionSheetRef(options) + showActionSheetRef: jest.fn() })); -const setStoreState = (overrides: Partial<ReturnType<typeof useCallStore.getState>> = {}) => { - useCallStore.setState({ - call: {} as any, - callId: 'test-id', - callState: 'active', - isMuted: false, - isOnHold: false, - isSpeakerOn: false, - callStartTime: Date.now(), - contact: { - displayName: 'Bob Burnquist', - username: 'bob.burnquist', - sipExtension: '2244' - }, - ...overrides - }); -}; +const mockNavigateToCallRoom = jest.mocked(navigateToCallRoom); const Wrapper = ({ children }: { children: React.ReactNode }) => <Provider store={mockedStore}>{children}</Provider>; @@ -36,10 +24,24 @@ describe('CallButtons', () => { beforeEach(() => { useCallStore.getState().reset(); jest.clearAllMocks(); + useCallStore.setState({ + call: { state: 'active', contact: {} } as any, + callState: 'active', + callId: 'id', + isMuted: false, + isOnHold: false, + isSpeakerOn: false, + roomId: 'rid-1', + contact: { username: 'u', sipExtension: '', displayName: 'U' }, + toggleMute: jest.fn(), + toggleHold: jest.fn(), + toggleSpeaker: jest.fn(), + endCall: jest.fn() + }); }); it('should set pointerEvents to none when controlsVisible is false', () => { - setStoreState({ controlsVisible: false }); + useCallStore.setState({ controlsVisible: false }); const { getByTestId } = render( <Wrapper> <CallButtons /> @@ -51,7 +53,7 @@ describe('CallButtons', () => { }); it('should set pointerEvents to auto when controlsVisible is true', () => { - setStoreState({ controlsVisible: true }); + useCallStore.setState({ controlsVisible: true }); const { getByTestId } = render( <Wrapper> <CallButtons /> @@ -61,4 +63,38 @@ describe('CallButtons', () => { const container = getByTestId('call-buttons'); expect(container.props.pointerEvents).toBe('auto'); }); + + it('message button calls navigateToCallRoom when enabled', () => { + const { getByTestId } = render( + <Wrapper> + <CallButtons /> + </Wrapper> + ); + fireEvent.press(getByTestId('call-view-message')); + expect(mockNavigateToCallRoom).toHaveBeenCalledTimes(1); + }); + + it('message button is disabled for SIP calls', () => { + useCallStore.setState({ + contact: { username: 'u', sipExtension: '100', displayName: 'U' } + }); + const { getByTestId } = render( + <Wrapper> + <CallButtons /> + </Wrapper> + ); + fireEvent.press(getByTestId('call-view-message')); + expect(mockNavigateToCallRoom).not.toHaveBeenCalled(); + }); + + it('message button is disabled when roomId is null', () => { + useCallStore.setState({ roomId: null }); + const { getByTestId } = render( + <Wrapper> + <CallButtons /> + </Wrapper> + ); + fireEvent.press(getByTestId('call-view-message')); + expect(mockNavigateToCallRoom).not.toHaveBeenCalled(); + }); }); diff --git a/app/views/CallView/components/CallButtons.tsx b/app/views/CallView/components/CallButtons.tsx index 0738906ed0a..ffeeff62479 100644 --- a/app/views/CallView/components/CallButtons.tsx +++ b/app/views/CallView/components/CallButtons.tsx @@ -3,6 +3,7 @@ import { View } from 'react-native'; import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'; import I18n from '../../../i18n'; +import { navigateToCallRoom } from '../../../lib/services/voip/navigateToCallRoom'; import { useCallStore, useControlsVisible } from '../../../lib/services/voip/useCallStore'; import CallActionButton from './CallActionButton'; import { CONTROLS_ANIMATION_DURATION, styles } from '../styles'; @@ -19,6 +20,8 @@ export const CallButtons = () => { const isMuted = useCallStore(state => state.isMuted); const isOnHold = useCallStore(state => state.isOnHold); const isSpeakerOn = useCallStore(state => state.isSpeakerOn); + const roomId = useCallStore(state => state.roomId); + const contact = useCallStore(state => state.contact); const toggleMute = useCallStore(state => state.toggleMute); const toggleHold = useCallStore(state => state.toggleHold); @@ -33,11 +36,10 @@ export const CallButtons = () => { })); const isConnecting = callState === 'none' || callState === 'ringing' || callState === 'accepted'; + const messageDisabled = Boolean(contact.sipExtension) || roomId == null; const handleMessage = () => { - // TODO: Navigate to chat with caller - // Navigation.navigate('RoomView', { rid, t: 'd' }); - alert('Message'); + navigateToCallRoom().catch(() => undefined); }; const handleDialpad = () => { @@ -81,7 +83,13 @@ export const CallButtons = () => { </View> <View style={styles.buttonsRow}> - <CallActionButton icon='message' label={I18n.t('Message')} onPress={handleMessage} testID='call-view-message' /> + <CallActionButton + icon='message' + label={I18n.t('Message')} + onPress={handleMessage} + disabled={messageDisabled} + testID='call-view-message' + /> <CallActionButton icon='phone-off' label={isConnecting ? I18n.t('Cancel') : I18n.t('End')} diff --git a/app/views/CallView/components/Dialpad/Dialpad.test.tsx b/app/views/CallView/components/Dialpad/Dialpad.test.tsx index a0f94e8eec0..9a10d799cb3 100644 --- a/app/views/CallView/components/Dialpad/Dialpad.test.tsx +++ b/app/views/CallView/components/Dialpad/Dialpad.test.tsx @@ -8,12 +8,6 @@ import { mockedStore } from '../../../../reducers/mockedStore'; import * as stories from './Dialpad.stories'; import { generateSnapshots } from '../../../../../.rnstorybook/generateSnapshots'; -jest.mock('react-native-incall-manager', () => ({ - start: jest.fn(), - stop: jest.fn(), - setForceSpeakerphoneOn: jest.fn(() => Promise.resolve()) -})); - const sendDTMFMock = jest.fn(); // Helper to set store state for tests diff --git a/app/views/CallView/index.test.tsx b/app/views/CallView/index.test.tsx index e2589a44ab3..dc08c12ddf7 100644 --- a/app/views/CallView/index.test.tsx +++ b/app/views/CallView/index.test.tsx @@ -3,11 +3,18 @@ import { fireEvent, render } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import CallView from '.'; +import { navigateToCallRoom } from '../../lib/services/voip/navigateToCallRoom'; import { useCallStore } from '../../lib/services/voip/useCallStore'; import { mockedStore } from '../../reducers/mockedStore'; import * as stories from './CallView.stories'; import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots'; +const mockNavigateToCallRoom = jest.mocked(navigateToCallRoom); + +jest.mock('../../lib/services/voip/navigateToCallRoom', () => ({ + navigateToCallRoom: jest.fn().mockResolvedValue(undefined) +})); + // Mock ResponsiveLayoutContext for snapshots jest.mock('../../lib/hooks/useResponsiveLayout/useResponsiveLayout', () => { const React = require('react'); @@ -26,9 +33,6 @@ jest.mock('../../lib/hooks/useResponsiveLayout/useResponsiveLayout', () => { }; }); -// Mock alert -global.alert = jest.fn(); - const mockShowActionSheetRef = jest.fn(); jest.mock('../../containers/ActionSheet', () => ({ ...jest.requireActual('../../containers/ActionSheet'), @@ -43,7 +47,7 @@ const createMockCall = (overrides: any = {}) => ({ contact: { displayName: 'Bob Burnquist', username: 'bob.burnquist', - sipExtension: '2244' + sipExtension: '' }, setMuted: jest.fn(), setHeld: jest.fn(), @@ -70,8 +74,9 @@ const setStoreState = (overrides: Partial<ReturnType<typeof useCallStore.getStat contact: { displayName: 'Bob Burnquist', username: 'bob.burnquist', - sipExtension: '2244' + sipExtension: '' }, + roomId: 'test-room-rid', ...overrides }); }; @@ -241,7 +246,7 @@ describe('CallView', () => { expect(endCall).toHaveBeenCalledTimes(1); }); - it('should show alert when message button is pressed', () => { + it('should call navigateToCallRoom when message button is pressed', () => { setStoreState({ callState: 'active' }); const { getByTestId } = render( <Wrapper> @@ -250,7 +255,7 @@ describe('CallView', () => { ); fireEvent.press(getByTestId('call-view-message')); - expect(global.alert).toHaveBeenCalledWith('Message'); + expect(mockNavigateToCallRoom).toHaveBeenCalledTimes(1); }); it('should show action sheet with dialpad when dialpad button is pressed', () => { diff --git a/jest.setup.js b/jest.setup.js index f663e2fc87d..a2dbc5a37af 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -38,6 +38,12 @@ jest.mock('react-native-file-viewer', () => ({ open: jest.fn(() => null) })); +jest.mock('react-native-incall-manager', () => ({ + start: jest.fn(), + stop: jest.fn(), + setForceSpeakerphoneOn: jest.fn(() => Promise.resolve()) +})); + jest.mock('expo-haptics', () => ({ impactAsync: jest.fn(), ImpactFeedbackStyle: {