Skip to content

Commit 1e2e6e8

Browse files
authored
fix(voip): CallView button grid and correct landscape/dialpad layouts (#7164)
1 parent 80663c7 commit 1e2e6e8

13 files changed

Lines changed: 1211 additions & 564 deletions

File tree

app/views/CallView/__snapshots__/index.test.tsx.snap

Lines changed: 822 additions & 342 deletions
Large diffs are not rendered by default.

app/views/CallView/components/CallActionButton.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const CallActionButton = ({
5858
};
5959

6060
return (
61-
<View>
61+
<View style={styles.actionButtonCell}>
6262
<Pressable
6363
onPress={handlePress}
6464
disabled={disabled}
@@ -72,7 +72,9 @@ const CallActionButton = ({
7272
testID={testID}>
7373
<CustomIcon name={icon} size={32} color={getIconColor()} />
7474
</Pressable>
75-
<Text style={[styles.actionButtonLabel, { color: colors.fontDefault }]}>{label}</Text>
75+
<Text numberOfLines={1} style={[styles.actionButtonLabel, { color: colors.fontDefault }]}>
76+
{label}
77+
</Text>
7678
</View>
7779
);
7880
};
Lines changed: 122 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,191 +1,182 @@
11
import React from 'react';
2-
import { fireEvent, render, within } from '@testing-library/react-native';
2+
import { render } from '@testing-library/react-native';
33
import { Provider } from 'react-redux';
44

55
import { mockedStore } from '../../../reducers/mockedStore';
66
import { useCallStore } from '../../../lib/services/voip/useCallStore';
7-
import { navigateToCallRoom } from '../../../lib/services/voip/navigateToCallRoom';
87
import { CallButtons } from './CallButtons';
98
import { useCallLayoutMode } from '../useCallLayoutMode';
10-
11-
jest.mock('../../../lib/services/voip/navigateToCallRoom', () => ({
12-
navigateToCallRoom: jest.fn().mockResolvedValue(undefined)
13-
}));
9+
import { useResponsiveLayout } from '../../../lib/hooks/useResponsiveLayout/useResponsiveLayout';
1410

1511
jest.mock('../useCallLayoutMode', () => ({
1612
useCallLayoutMode: jest.fn(() => ({ layoutMode: 'narrow' }))
1713
}));
1814

19-
const mockUseCallLayoutMode = jest.mocked(useCallLayoutMode);
20-
21-
const mockShowActionSheetRef = jest.fn();
2215
jest.mock('../../../containers/ActionSheet', () => ({
23-
showActionSheetRef: (options: any) => mockShowActionSheetRef(options),
16+
showActionSheetRef: jest.fn(),
2417
hideActionSheetRef: jest.fn()
2518
}));
2619

27-
const mockNavigateToCallRoom = jest.mocked(navigateToCallRoom);
20+
jest.mock('react-native-incall-manager', () => ({
21+
start: jest.fn(),
22+
stop: jest.fn(),
23+
setForceSpeakerphoneOn: jest.fn(() => Promise.resolve())
24+
}));
25+
26+
jest.mock('../../../lib/hooks/useResponsiveLayout/useResponsiveLayout', () => ({
27+
useResponsiveLayout: jest.fn(() => ({ width: 375, height: 812 }))
28+
}));
29+
30+
const setStoreState = (overrides: Partial<ReturnType<typeof useCallStore.getState>> = {}) => {
31+
const mockCall = {
32+
state: 'active',
33+
muted: false,
34+
held: false,
35+
contact: {},
36+
sendDTMF: jest.fn(),
37+
emitter: { on: jest.fn(), off: jest.fn() }
38+
} as any;
39+
40+
useCallStore.setState({
41+
call: mockCall,
42+
callId: 'test-id',
43+
callState: 'active',
44+
isMuted: false,
45+
isOnHold: false,
46+
isSpeakerOn: false,
47+
roomId: 'room-1',
48+
contact: { name: 'Test User' } as any,
49+
toggleMute: jest.fn(),
50+
toggleHold: jest.fn(),
51+
toggleSpeaker: jest.fn(),
52+
endCall: jest.fn(),
53+
dialpadValue: '',
54+
...overrides
55+
});
56+
};
2857

2958
const Wrapper = ({ children }: { children: React.ReactNode }) => <Provider store={mockedStore}>{children}</Provider>;
3059

3160
describe('CallButtons', () => {
3261
beforeEach(() => {
62+
(useCallLayoutMode as jest.Mock).mockReturnValue({ layoutMode: 'narrow' });
63+
(useResponsiveLayout as jest.Mock).mockReturnValue({ width: 375, height: 812 });
3364
useCallStore.getState().reset();
34-
jest.clearAllMocks();
35-
mockUseCallLayoutMode.mockReturnValue({ layoutMode: 'narrow' });
36-
useCallStore.setState({
37-
call: { state: 'active', contact: {} } as any,
38-
callState: 'active',
39-
callId: 'id',
40-
isMuted: false,
41-
isOnHold: false,
42-
isSpeakerOn: false,
43-
roomId: 'rid-1',
44-
contact: { username: 'u', sipExtension: '', displayName: 'U' },
45-
toggleMute: jest.fn(),
46-
toggleHold: jest.fn(),
47-
toggleSpeaker: jest.fn(),
48-
endCall: jest.fn()
49-
});
5065
});
5166

52-
it('should set pointerEvents to none when controlsVisible is false', () => {
53-
useCallStore.setState({ controlsVisible: false });
67+
it('renders all 6 buttons', () => {
68+
setStoreState();
5469
const { getByTestId } = render(
5570
<Wrapper>
5671
<CallButtons />
5772
</Wrapper>
5873
);
59-
60-
const container = getByTestId('call-buttons', { includeHiddenElements: true });
61-
expect(container.props.pointerEvents).toBe('none');
74+
expect(getByTestId('call-view-speaker')).toBeTruthy();
75+
expect(getByTestId('call-view-hold')).toBeTruthy();
76+
expect(getByTestId('call-view-mute')).toBeTruthy();
77+
expect(getByTestId('call-view-message')).toBeTruthy();
78+
expect(getByTestId('call-view-end')).toBeTruthy();
79+
expect(getByTestId('call-view-dialpad')).toBeTruthy();
6280
});
6381

64-
it('should set pointerEvents to auto when controlsVisible is true', () => {
65-
useCallStore.setState({ controlsVisible: true });
82+
it('narrow layout renders 2 rows', () => {
83+
(useCallLayoutMode as jest.Mock).mockReturnValue({ layoutMode: 'narrow' });
84+
setStoreState();
6685
const { getByTestId } = render(
6786
<Wrapper>
6887
<CallButtons />
6988
</Wrapper>
7089
);
90+
expect(getByTestId('call-buttons-row-0')).toBeTruthy();
91+
expect(getByTestId('call-buttons-row-1')).toBeTruthy();
92+
});
7193

72-
const container = getByTestId('call-buttons');
73-
expect(container.props.pointerEvents).toBe('auto');
94+
it('wide layout renders 1 row', () => {
95+
(useCallLayoutMode as jest.Mock).mockReturnValue({ layoutMode: 'wide' });
96+
setStoreState();
97+
const { getByTestId, queryByTestId } = render(
98+
<Wrapper>
99+
<CallButtons />
100+
</Wrapper>
101+
);
102+
expect(getByTestId('call-buttons-row-0')).toBeTruthy();
103+
expect(queryByTestId('call-buttons-row-1')).toBeNull();
74104
});
75105

76-
it('message button calls navigateToCallRoom when enabled', () => {
77-
const { getByTestId } = render(
106+
it('narrow phone landscape renders 1 row', () => {
107+
(useCallLayoutMode as jest.Mock).mockReturnValue({ layoutMode: 'narrow' });
108+
(useResponsiveLayout as jest.Mock).mockReturnValue({ width: 600, height: 400 });
109+
setStoreState();
110+
const { getByTestId, queryByTestId } = render(
78111
<Wrapper>
79112
<CallButtons />
80113
</Wrapper>
81114
);
82-
fireEvent.press(getByTestId('call-view-message'));
83-
expect(mockNavigateToCallRoom).toHaveBeenCalledTimes(1);
115+
expect(getByTestId('call-buttons-row-0')).toBeTruthy();
116+
expect(queryByTestId('call-buttons-row-1')).toBeNull();
84117
});
85118

86-
it('message button is disabled for SIP calls', () => {
87-
useCallStore.setState({
88-
contact: { username: 'u', sipExtension: '100', displayName: 'U' }
89-
});
90-
const { getByTestId } = render(
119+
it('button labels render', () => {
120+
setStoreState();
121+
const { getByText } = render(
91122
<Wrapper>
92123
<CallButtons />
93124
</Wrapper>
94125
);
95-
fireEvent.press(getByTestId('call-view-message'));
96-
expect(mockNavigateToCallRoom).not.toHaveBeenCalled();
126+
expect(getByText('Speaker')).toBeTruthy();
127+
expect(getByText('Hold')).toBeTruthy();
128+
expect(getByText('Mute')).toBeTruthy();
129+
expect(getByText('Message')).toBeTruthy();
130+
expect(getByText('End')).toBeTruthy();
131+
expect(getByText('Dialpad')).toBeTruthy();
97132
});
98133

99-
it('message button is disabled when roomId is null', () => {
100-
useCallStore.setState({ roomId: null });
134+
it('disabled states when ringing', () => {
135+
setStoreState({ callState: 'ringing' });
101136
const { getByTestId } = render(
102137
<Wrapper>
103138
<CallButtons />
104139
</Wrapper>
105140
);
106-
fireEvent.press(getByTestId('call-view-message'));
107-
expect(mockNavigateToCallRoom).not.toHaveBeenCalled();
141+
expect(getByTestId('call-view-speaker').props.accessibilityState?.disabled).toBe(true);
142+
expect(getByTestId('call-view-hold').props.accessibilityState?.disabled).toBe(true);
143+
expect(getByTestId('call-view-mute').props.accessibilityState?.disabled).toBe(true);
144+
expect(getByTestId('call-view-dialpad').props.accessibilityState?.disabled).toBe(true);
145+
});
146+
147+
it('mute toggle label shows Unmute when isMuted is true, Mute when false', () => {
148+
setStoreState({ isMuted: true });
149+
const { getByText, rerender } = render(
150+
<Wrapper>
151+
<CallButtons />
152+
</Wrapper>
153+
);
154+
expect(getByText('Unmute')).toBeTruthy();
155+
156+
setStoreState({ isMuted: false });
157+
rerender(
158+
<Wrapper>
159+
<CallButtons />
160+
</Wrapper>
161+
);
162+
expect(getByText('Mute')).toBeTruthy();
108163
});
109164

110-
describe('layoutMode prop', () => {
111-
it('renders two button rows on narrow layout', () => {
112-
const { getByTestId } = render(
113-
<Wrapper>
114-
<CallButtons />
115-
</Wrapper>
116-
);
117-
expect(getByTestId('call-buttons-row-0')).toBeTruthy();
118-
expect(getByTestId('call-buttons-row-1')).toBeTruthy();
119-
});
120-
121-
it('renders a single button row on wide layout', () => {
122-
mockUseCallLayoutMode.mockReturnValue({ layoutMode: 'wide' });
123-
const { getByTestId, queryByTestId } = render(
124-
<Wrapper>
125-
<CallButtons />
126-
</Wrapper>
127-
);
128-
expect(getByTestId('call-buttons-row-0')).toBeTruthy();
129-
expect(queryByTestId('call-buttons-row-1')).toBeNull();
130-
});
131-
132-
it('renders all six action buttons regardless of layoutMode', () => {
133-
const ids = [
134-
'call-view-speaker',
135-
'call-view-hold',
136-
'call-view-mute',
137-
'call-view-message',
138-
'call-view-end',
139-
'call-view-dialpad'
140-
];
141-
(['narrow', 'wide'] as const).forEach(layoutMode => {
142-
mockUseCallLayoutMode.mockReturnValue({ layoutMode });
143-
const { getByTestId, unmount } = render(
144-
<Wrapper>
145-
<CallButtons />
146-
</Wrapper>
147-
);
148-
ids.forEach(id => expect(getByTestId(id)).toBeTruthy());
149-
unmount();
150-
});
151-
});
152-
153-
it('places every action button inside row 0 on wide layout', () => {
154-
mockUseCallLayoutMode.mockReturnValue({ layoutMode: 'wide' });
155-
const { getByTestId } = render(
156-
<Wrapper>
157-
<CallButtons />
158-
</Wrapper>
159-
);
160-
const row0 = getByTestId('call-buttons-row-0');
161-
const ids = [
162-
'call-view-speaker',
163-
'call-view-hold',
164-
'call-view-mute',
165-
'call-view-message',
166-
'call-view-end',
167-
'call-view-dialpad'
168-
];
169-
ids.forEach(id => {
170-
expect(within(row0).getByTestId(id)).toBeTruthy();
171-
});
172-
});
173-
174-
it('splits buttons across row 0 and row 1 on narrow layout', () => {
175-
const { getByTestId } = render(
176-
<Wrapper>
177-
<CallButtons />
178-
</Wrapper>
179-
);
180-
const row0 = getByTestId('call-buttons-row-0');
181-
const row1 = getByTestId('call-buttons-row-1');
182-
183-
expect(within(row0).getByTestId('call-view-speaker')).toBeTruthy();
184-
expect(within(row0).getByTestId('call-view-hold')).toBeTruthy();
185-
expect(within(row0).getByTestId('call-view-mute')).toBeTruthy();
186-
expect(within(row1).getByTestId('call-view-message')).toBeTruthy();
187-
expect(within(row1).getByTestId('call-view-end')).toBeTruthy();
188-
expect(within(row1).getByTestId('call-view-dialpad')).toBeTruthy();
189-
});
165+
it('end/cancel label shows Cancel when ringing, End when active', () => {
166+
setStoreState({ callState: 'ringing' });
167+
const { getByText, rerender } = render(
168+
<Wrapper>
169+
<CallButtons />
170+
</Wrapper>
171+
);
172+
expect(getByText('Cancel')).toBeTruthy();
173+
174+
setStoreState({ callState: 'active' });
175+
rerender(
176+
<Wrapper>
177+
<CallButtons />
178+
</Wrapper>
179+
);
180+
expect(getByText('End')).toBeTruthy();
190181
});
191182
});

app/views/CallView/components/CallButtons.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const CallButtons = () => {
3030
const { layoutMode } = useCallLayoutMode();
3131
const { width, height } = useResponsiveLayout();
3232
const isLandscape = width > height;
33+
const singleRow = layoutMode === 'wide' || isLandscape;
3334

3435
const callState = useCallStore(state => state.callState);
3536
const isMuted = useCallStore(state => state.isMuted);
@@ -117,18 +118,16 @@ export const CallButtons = () => {
117118
return (
118119
<Animated.View
119120
style={[
120-
isLandscape ? styles.buttonsContainerLandscape : styles.buttonsContainer,
121-
isLandscape
122-
? { borderLeftColor: colors.strokeExtraLight, backgroundColor: colors.surfaceLight }
123-
: { borderTopColor: colors.strokeExtraLight, backgroundColor: colors.surfaceLight },
121+
styles.buttonsContainer,
122+
{ borderTopColor: colors.strokeExtraLight, backgroundColor: colors.surfaceLight },
124123
containerStyle
125124
]}
126125
pointerEvents={controlsVisible ? 'auto' : 'none'}
127126
accessibilityElementsHidden={!controlsVisible}
128127
importantForAccessibility={controlsVisible ? 'auto' : 'no-hide-descendants'}
129128
testID='call-buttons'>
130-
{layoutMode === 'wide' ? (
131-
<View style={[styles.buttonsRow, isLandscape && styles.buttonsRowLandscape]} testID='call-buttons-row-0'>
129+
{singleRow ? (
130+
<View style={styles.buttonsRow} testID='call-buttons-row-0'>
132131
{buttons.map(btn => (
133132
<CallActionButton
134133
key={btn.testID}
@@ -143,7 +142,7 @@ export const CallButtons = () => {
143142
</View>
144143
) : (
145144
<>
146-
<View style={[styles.buttonsRow, isLandscape && styles.buttonsRowLandscape]} testID='call-buttons-row-0'>
145+
<View style={styles.buttonsRow} testID='call-buttons-row-0'>
147146
{buttons.slice(0, 3).map(btn => (
148147
<CallActionButton
149148
key={btn.testID}
@@ -156,7 +155,7 @@ export const CallButtons = () => {
156155
/>
157156
))}
158157
</View>
159-
<View style={[styles.buttonsRow, isLandscape && styles.buttonsRowLandscape]} testID='call-buttons-row-1'>
158+
<View style={styles.buttonsRow} testID='call-buttons-row-1'>
160159
{buttons.slice(3, 6).map(btn => (
161160
<CallActionButton
162161
key={btn.testID}

0 commit comments

Comments
 (0)