diff --git a/app/containers/MediaCallHeader/MediaCallHeader.test.tsx b/app/containers/MediaCallHeader/MediaCallHeader.test.tsx
index bf229800b18..1570fb0632e 100644
--- a/app/containers/MediaCallHeader/MediaCallHeader.test.tsx
+++ b/app/containers/MediaCallHeader/MediaCallHeader.test.tsx
@@ -176,6 +176,39 @@ describe('MediaCallHeader', () => {
expect(endCall).toHaveBeenCalledTimes(1);
});
+ it('should have pointerEvents none when focused and controls hidden', () => {
+ setStoreState({ focused: true, controlsVisible: false });
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('media-call-header')).toHaveProp('pointerEvents', 'none');
+ });
+
+ it('should have pointerEvents auto when focused and controls visible', () => {
+ setStoreState({ focused: true, controlsVisible: true });
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('media-call-header')).toHaveProp('pointerEvents', 'auto');
+ });
+
+ it('should have pointerEvents auto when not focused even if controls hidden', () => {
+ setStoreState({ focused: false, controlsVisible: false });
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('media-call-header')).toHaveProp('pointerEvents', 'auto');
+ });
+
it('should show alert when content is pressed', () => {
setStoreState();
const { getByTestId } = render(
diff --git a/app/containers/MediaCallHeader/MediaCallHeader.tsx b/app/containers/MediaCallHeader/MediaCallHeader.tsx
index 2e7d508d793..f2e7471d08b 100644
--- a/app/containers/MediaCallHeader/MediaCallHeader.tsx
+++ b/app/containers/MediaCallHeader/MediaCallHeader.tsx
@@ -1,12 +1,14 @@
import { StyleSheet, View } from 'react-native';
+import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useShallow } from 'zustand/react/shallow';
import { useTheme } from '../../theme';
import Collapse from './components/Collapse';
import EndCall from './components/EndCall';
-import { useCallStore } from '../../lib/services/voip/useCallStore';
+import { useCallStore, useControlsVisible } from '../../lib/services/voip/useCallStore';
import { Content } from './components/Content';
+import { CONTROLS_ANIMATION_DURATION } from '../../views/CallView/styles';
const styles = StyleSheet.create({
header: {
@@ -25,6 +27,23 @@ const MediaCallHeader = () => {
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const call = useCallStore(useShallow(state => state.call));
+ const focused = useCallStore(state => state.focused);
+ const controlsVisible = useControlsVisible();
+
+ const shouldHide = focused && !controlsVisible;
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: withTiming(shouldHide ? 0 : 1, { duration: CONTROLS_ANIMATION_DURATION }),
+ transform: [
+ {
+ translateY: withTiming(shouldHide ? -100 : 0, {
+ duration: CONTROLS_ANIMATION_DURATION
+ })
+ }
+ ],
+ backgroundColor: withTiming(shouldHide ? 'transparent' : colors.surfaceNeutral, { duration: CONTROLS_ANIMATION_DURATION }),
+ borderBottomColor: withTiming(shouldHide ? 'transparent' : colors.strokeLight, { duration: CONTROLS_ANIMATION_DURATION })
+ }));
const defaultHeaderStyle = {
backgroundColor: colors.surfaceNeutral,
@@ -36,13 +55,18 @@ const MediaCallHeader = () => {
}
return (
-
-
+
);
};
diff --git a/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap b/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap
index 86f5b4ca9d3..689dfc85e09 100644
--- a/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap
+++ b/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap
@@ -9,6 +9,7 @@ exports[`Story Snapshots: ActiveCall should match snapshot 1`] = `
}
>
({
setAvailable: jest.fn()
}
}));
-
jest.mock('react-native-device-info', () => ({
getUniqueId: jest.fn(() => 'test-device-id'),
getUniqueIdSync: jest.fn(() => 'test-device-id')
diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts
index c112f99cab3..8b137825b8b 100644
--- a/app/lib/services/voip/useCallStore.test.ts
+++ b/app/lib/services/voip/useCallStore.test.ts
@@ -19,7 +19,7 @@ jest.mock('react-native-incall-manager', () => ({
setForceSpeakerphoneOn: jest.fn()
}));
-function createMockCall(callId: string): IClientMediaCall {
+function createMockCall(callId: string) {
const listeners: Record void>> = {};
const emitter = {
on: (ev: string, fn: (...args: unknown[]) => void) => {
@@ -30,7 +30,10 @@ function createMockCall(callId: string): IClientMediaCall {
listeners[ev]?.delete(fn);
}
};
- return {
+ const emit = (ev: string, ...args: unknown[]) => {
+ listeners[ev]?.forEach(fn => fn(...args));
+ };
+ const call = {
callId,
state: 'active',
muted: false,
@@ -48,8 +51,85 @@ function createMockCall(callId: string): IClientMediaCall {
accept: jest.fn(),
reject: jest.fn()
} as unknown as IClientMediaCall;
+ return { call, emit };
}
+describe('createMockCall emitter', () => {
+ it('forwards variadic arguments to listeners', () => {
+ const { call, emit } = createMockCall('e1');
+ const listener = jest.fn();
+ call.emitter.on('stateChange', listener);
+
+ emit('stateChange', { kind: 'test' }, 2);
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenCalledWith({ kind: 'test' }, 2);
+ });
+});
+
+describe('useCallStore controlsVisible', () => {
+ beforeEach(() => {
+ useCallStore.getState().resetNativeCallId();
+ useCallStore.getState().reset();
+ });
+
+ it('defaults to true', () => {
+ expect(useCallStore.getState().controlsVisible).toBe(true);
+ });
+
+ it('toggleControlsVisible flips the value', () => {
+ useCallStore.getState().toggleControlsVisible();
+ expect(useCallStore.getState().controlsVisible).toBe(false);
+ useCallStore.getState().toggleControlsVisible();
+ expect(useCallStore.getState().controlsVisible).toBe(true);
+ });
+
+ it('showControls sets true when hidden', () => {
+ useCallStore.getState().toggleControlsVisible();
+ expect(useCallStore.getState().controlsVisible).toBe(false);
+ useCallStore.getState().showControls();
+ expect(useCallStore.getState().controlsVisible).toBe(true);
+ });
+
+ it('auto-shows controls on stateChange event', () => {
+ const { call, emit } = createMockCall('c1');
+ useCallStore.getState().setCall(call);
+ useCallStore.getState().toggleControlsVisible();
+ expect(useCallStore.getState().controlsVisible).toBe(false);
+
+ emit('stateChange');
+
+ expect(useCallStore.getState().controlsVisible).toBe(true);
+ });
+
+ it('auto-shows controls on trackStateChange event', () => {
+ const { call, emit } = createMockCall('c2');
+ useCallStore.getState().setCall(call);
+ useCallStore.getState().toggleControlsVisible();
+ expect(useCallStore.getState().controlsVisible).toBe(false);
+
+ emit('trackStateChange');
+
+ expect(useCallStore.getState().controlsVisible).toBe(true);
+ });
+
+ it('toggleFocus always shows controls', () => {
+ useCallStore.getState().toggleControlsVisible();
+ expect(useCallStore.getState().controlsVisible).toBe(false);
+
+ useCallStore.getState().toggleFocus();
+
+ expect(useCallStore.getState().controlsVisible).toBe(true);
+ });
+
+ it('reset restores controlsVisible to true', () => {
+ useCallStore.getState().toggleControlsVisible();
+ expect(useCallStore.getState().controlsVisible).toBe(false);
+ useCallStore.getState().reset();
+ expect(useCallStore.getState().controlsVisible).toBe(true);
+ });
+});
+
describe('useCallStore native accepted + stale timer', () => {
beforeEach(() => {
jest.useFakeTimers();
@@ -104,7 +184,7 @@ describe('useCallStore native accepted + stale timer', () => {
it('setCall clears native id and cancels stale timer so advance does not clear bound call context', () => {
useCallStore.getState().setNativeAcceptedCallId('x');
- useCallStore.getState().setCall(createMockCall('x'));
+ useCallStore.getState().setCall(createMockCall('x').call);
jest.advanceTimersByTime(15_000);
expect(useCallStore.getState().call).not.toBeNull();
expect(useCallStore.getState().nativeAcceptedCallId).toBeNull();
diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts
index c97ffd59db1..28b311769ff 100644
--- a/app/lib/services/voip/useCallStore.ts
+++ b/app/lib/services/voip/useCallStore.ts
@@ -68,6 +68,7 @@ interface CallStoreState {
isSpeakerOn: boolean;
callStartTime: number | null;
focused: boolean;
+ controlsVisible: boolean;
dialpadValue: string;
// Contact info
@@ -83,6 +84,8 @@ interface CallStoreActions {
toggleMute: () => void;
toggleHold: () => void;
toggleSpeaker: () => void;
+ toggleControlsVisible: () => void;
+ showControls: () => void;
toggleFocus: () => void;
endCall: () => void;
/** Clears UI/call fields but keeps nativeAcceptedCallId. Restarts the 15s timer (media init calls reset and clears the old timer first). */
@@ -105,6 +108,7 @@ const initialState: CallStoreState = {
callStartTime: null,
contact: {},
focused: true,
+ controlsVisible: true,
dialpadValue: ''
};
@@ -159,7 +163,7 @@ export const useCallStore = create((set, get) => ({
if (!currentCall) return;
const newState = currentCall.state;
- set({ callState: newState });
+ set({ callState: newState, controlsVisible: true });
// Set start time when call becomes active
if (newState === 'active' && !get().callStartTime) {
@@ -175,7 +179,8 @@ export const useCallStore = create((set, get) => ({
isMuted: currentCall.muted,
isOnHold: currentCall.held,
remoteMute: currentCall.remoteMute,
- remoteHeld: currentCall.remoteHeld
+ remoteHeld: currentCall.remoteHeld,
+ controlsVisible: true
});
};
@@ -196,6 +201,14 @@ export const useCallStore = create((set, get) => ({
};
},
+ toggleControlsVisible: () => {
+ set({ controlsVisible: !get().controlsVisible });
+ },
+
+ showControls: () => {
+ set({ controlsVisible: true });
+ },
+
toggleMute: () => {
const { call, isMuted } = get();
if (!call) return;
@@ -228,7 +241,7 @@ export const useCallStore = create((set, get) => ({
toggleFocus: () => {
const isFocused = get().focused;
- set({ focused: !isFocused });
+ set({ focused: !isFocused, controlsVisible: true });
if (isFocused) {
Navigation.back();
} else {
@@ -285,3 +298,4 @@ export const useCallState = () => {
export const useCallContact = () => useCallStore(state => state.contact);
export const useDialpadValue = () => useCallStore(state => state.dialpadValue);
+export const useControlsVisible = () => useCallStore(state => state.controlsVisible);
diff --git a/app/views/CallView/__snapshots__/index.test.tsx.snap b/app/views/CallView/__snapshots__/index.test.tsx.snap
index e8d65ca338e..213fd554fd5 100644
--- a/app/views/CallView/__snapshots__/index.test.tsx.snap
+++ b/app/views/CallView/__snapshots__/index.test.tsx.snap
@@ -22,6 +22,35 @@ exports[`Story Snapshots: ConnectedCall should match snapshot 1`] = `
}
>
({
+ ...jest.requireActual('../../../containers/ActionSheet'),
+ showActionSheetRef: (options: any) => mockShowActionSheetRef(options)
+}));
+
+const setStoreState = (overrides: Partial> = {}) => {
+ 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 Wrapper = ({ children }: { children: React.ReactNode }) => {children};
+
+describe('CallButtons', () => {
+ beforeEach(() => {
+ useCallStore.getState().reset();
+ jest.clearAllMocks();
+ });
+
+ it('should set pointerEvents to none when controlsVisible is false', () => {
+ setStoreState({ controlsVisible: false });
+ const { getByTestId } = render(
+
+
+
+ );
+
+ const container = getByTestId('call-buttons');
+ expect(container.props.pointerEvents).toBe('none');
+ });
+
+ it('should set pointerEvents to auto when controlsVisible is true', () => {
+ setStoreState({ controlsVisible: true });
+ const { getByTestId } = render(
+
+
+
+ );
+
+ const container = getByTestId('call-buttons');
+ expect(container.props.pointerEvents).toBe('auto');
+ });
+});
diff --git a/app/views/CallView/components/CallButtons.tsx b/app/views/CallView/components/CallButtons.tsx
index 831bc05ffc0..0738906ed0a 100644
--- a/app/views/CallView/components/CallButtons.tsx
+++ b/app/views/CallView/components/CallButtons.tsx
@@ -1,10 +1,11 @@
import React from 'react';
import { View } from 'react-native';
+import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated';
import I18n from '../../../i18n';
-import { useCallStore } from '../../../lib/services/voip/useCallStore';
+import { useCallStore, useControlsVisible } from '../../../lib/services/voip/useCallStore';
import CallActionButton from './CallActionButton';
-import { styles } from '../styles';
+import { CONTROLS_ANIMATION_DURATION, styles } from '../styles';
import { useTheme } from '../../../theme';
import { showActionSheetRef } from '../../../containers/ActionSheet';
import Dialpad from './Dialpad/Dialpad';
@@ -24,6 +25,13 @@ export const CallButtons = () => {
const toggleSpeaker = useCallStore(state => state.toggleSpeaker);
const endCall = useCallStore(state => state.endCall);
+ const controlsVisible = useControlsVisible();
+
+ const containerStyle = useAnimatedStyle(() => ({
+ opacity: withTiming(controlsVisible ? 1 : 0, { duration: CONTROLS_ANIMATION_DURATION }),
+ transform: [{ translateY: withTiming(controlsVisible ? 0 : 100, { duration: CONTROLS_ANIMATION_DURATION }) }]
+ }));
+
const isConnecting = callState === 'none' || callState === 'ringing' || callState === 'accepted';
const handleMessage = () => {
@@ -41,7 +49,10 @@ export const CallButtons = () => {
};
return (
-
+
{
testID='call-view-dialpad'
/>
-
+
);
};
diff --git a/app/views/CallView/components/CallerInfo.test.tsx b/app/views/CallView/components/CallerInfo.test.tsx
index 7fbc6b88e9f..995bbf1c868 100644
--- a/app/views/CallView/components/CallerInfo.test.tsx
+++ b/app/views/CallView/components/CallerInfo.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { render } from '@testing-library/react-native';
+import { render, fireEvent } from '@testing-library/react-native';
import { Provider } from 'react-redux';
import CallerInfo from './CallerInfo';
@@ -39,7 +39,7 @@ describe('CallerInfo', () => {
);
- expect(getByTestId('caller-info')).toBeTruthy();
+ expect(getByTestId('caller-info-toggle')).toBeTruthy();
expect(getByText('Bob Burnquist')).toBeTruthy();
});
@@ -53,6 +53,21 @@ describe('CallerInfo', () => {
expect(getByText('john.doe')).toBeTruthy();
});
+
+ it('should toggle controlsVisible when pressing caller-info-toggle', () => {
+ setStoreState({ displayName: 'Bob Burnquist', username: 'bob.burnquist' });
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(useCallStore.getState().controlsVisible).toBe(true);
+ fireEvent.press(getByTestId('caller-info-toggle'));
+ expect(useCallStore.getState().controlsVisible).toBe(false);
+ fireEvent.press(getByTestId('caller-info-toggle'));
+ expect(useCallStore.getState().controlsVisible).toBe(true);
+ });
});
generateSnapshots(stories);
diff --git a/app/views/CallView/components/CallerInfo.tsx b/app/views/CallView/components/CallerInfo.tsx
index 32b5d856b7a..8a1f96f5862 100644
--- a/app/views/CallView/components/CallerInfo.tsx
+++ b/app/views/CallView/components/CallerInfo.tsx
@@ -1,30 +1,38 @@
import React from 'react';
-import { Text, View } from 'react-native';
+import { Pressable, Text, View } from 'react-native';
+import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated';
import AvatarContainer from '../../../containers/Avatar';
import I18n from '../../../i18n';
-import { useCallContact } from '../../../lib/services/voip/useCallStore';
-import { styles } from '../styles';
+import { useCallContact, useCallStore, useControlsVisible } from '../../../lib/services/voip/useCallStore';
+import { CONTROLS_ANIMATION_DURATION, styles } from '../styles';
import { useTheme } from '../../../theme';
const CallerInfo = (): React.ReactElement => {
const { colors } = useTheme();
const contact = useCallContact();
+ const toggleControlsVisible = useCallStore(state => state.toggleControlsVisible);
+ const controlsVisible = useControlsVisible();
+
+ const callerRowStyle = useAnimatedStyle(() => ({
+ opacity: withTiming(controlsVisible ? 1 : 0, { duration: CONTROLS_ANIMATION_DURATION }),
+ transform: [{ translateY: withTiming(controlsVisible ? 0 : 10, { duration: CONTROLS_ANIMATION_DURATION }) }]
+ }));
const name = contact.displayName || contact.username || I18n.t('Unknown');
const avatarText = contact.username || name;
return (
-
+
-
+
{name}
-
-
+
+
);
};
diff --git a/app/views/CallView/components/__snapshots__/CallerInfo.test.tsx.snap b/app/views/CallView/components/__snapshots__/CallerInfo.test.tsx.snap
index 4803ed73af8..097b492a88b 100644
--- a/app/views/CallView/components/__snapshots__/CallerInfo.test.tsx.snap
+++ b/app/views/CallView/components/__snapshots__/CallerInfo.test.tsx.snap
@@ -11,6 +11,35 @@ exports[`Story Snapshots: Default should match snapshot 1`] = `
}
>
{
);
- expect(queryByTestId('caller-info')).toBeNull();
+ expect(queryByTestId('caller-info-toggle')).toBeNull();
});
it('should render when there is a call', () => {
@@ -102,7 +102,7 @@ describe('CallView', () => {
);
- expect(getByTestId('caller-info')).toBeTruthy();
+ expect(getByTestId('caller-info-toggle')).toBeTruthy();
expect(getByTestId('call-view-speaker')).toBeTruthy();
expect(getByTestId('call-view-hold')).toBeTruthy();
expect(getByTestId('call-view-mute')).toBeTruthy();
@@ -132,9 +132,9 @@ describe('CallView', () => {
);
// CallStatusText should not be rendered when not active
- // We can't easily test for the Text component directly, so we verify the caller-info is rendered
+ // We can't easily test for the Text component directly, so we verify the caller-info-toggle is rendered
// but CallStatusText won't be in the tree when callState is not 'active'
- expect(queryByTestId('caller-info')).toBeTruthy();
+ expect(queryByTestId('caller-info-toggle')).toBeTruthy();
});
it('should disable hold and mute buttons when call is connecting', () => {
@@ -299,7 +299,7 @@ describe('CallView', () => {
);
- expect(getByTestId('caller-info')).toBeTruthy();
+ expect(getByTestId('caller-info-toggle')).toBeTruthy();
expect(getByTestId('call-view-mute')).toBeTruthy();
});
@@ -311,7 +311,7 @@ describe('CallView', () => {
);
- expect(getByTestId('caller-info')).toBeTruthy();
+ expect(getByTestId('caller-info-toggle')).toBeTruthy();
});
it('should show correct icon for speaker button when speaker is on', () => {
diff --git a/app/views/CallView/styles.ts b/app/views/CallView/styles.ts
index a22cd8ab119..d8eb8b74b0d 100644
--- a/app/views/CallView/styles.ts
+++ b/app/views/CallView/styles.ts
@@ -2,6 +2,8 @@ import { StyleSheet } from 'react-native';
import sharedStyles from '../Styles';
+export const CONTROLS_ANIMATION_DURATION = 300;
+
export const styles = StyleSheet.create({
container: {
flex: 1,