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,