From ba7972d4d969eae06563411ff06b8dc4ccb7921a Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 31 Mar 2026 11:12:08 -0300 Subject: [PATCH 01/12] feat(voip): add controlsVisible state and actions to call store Add toggle/show controls visibility for tap-to-hide animation support in CallView. Includes convenience selector and animation duration constant. --- app/lib/services/voip/useCallStore.test.ts | 32 ++++++++++++++++++++++ app/lib/services/voip/useCallStore.ts | 13 +++++++++ app/views/CallView/styles.ts | 2 ++ 3 files changed, 47 insertions(+) diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts index c112f99cab3..f66c41abe94 100644 --- a/app/lib/services/voip/useCallStore.test.ts +++ b/app/lib/services/voip/useCallStore.test.ts @@ -50,6 +50,38 @@ function createMockCall(callId: string): IClientMediaCall { } as unknown as IClientMediaCall; } +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('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(); diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index c97ffd59db1..657e68180e5 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: '' }; @@ -196,6 +200,14 @@ export const useCallStore = create((set, get) => ({ }; }, + toggleControlsVisible: () => { + set({ controlsVisible: !get().controlsVisible }); + }, + + showControls: () => { + set({ controlsVisible: true }); + }, + toggleMute: () => { const { call, isMuted } = get(); if (!call) return; @@ -285,3 +297,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/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, From 2a0af77fc968a5b7218a5d0224e259d4387b72d3 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 31 Mar 2026 11:16:11 -0300 Subject: [PATCH 02/12] feat(voip): tap CallerInfo to toggle controls visibility Wrap CallerInfo with Pressable to toggle controlsVisible on tap. Animate caller name row with fade + slide using Reanimated. Avatar remains always visible and centered. --- .../CallView/components/CallerInfo.test.tsx | 19 +++- app/views/CallView/components/CallerInfo.tsx | 22 ++-- .../__snapshots__/CallerInfo.test.tsx.snap | 102 +++++++++++++++--- 3 files changed, 122 insertions(+), 21 deletions(-) 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`] = ` } > Date: Tue, 31 Mar 2026 11:24:20 -0300 Subject: [PATCH 03/12] feat(voip): animate CallButtons slide-down + fade on toggle Wrap CallButtons in Animated.View with opacity and translateY animations driven by controlsVisible store state. Set pointerEvents to 'none' when hidden to block ghost taps. --- .../__snapshots__/index.test.tsx.snap | 366 ++++++++++++++++-- .../CallView/components/CallButtons.test.tsx | 64 +++ app/views/CallView/components/CallButtons.tsx | 20 +- 3 files changed, 410 insertions(+), 40 deletions(-) create mode 100644 app/views/CallView/components/CallButtons.test.tsx 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..10b89fe4886 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,11 @@ export const CallButtons = () => { }; return ( - + { testID='call-view-dialpad' /> - + ); }; From 520b1b4c6fda02ffc2eed07357e28d26a8a1a634 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 31 Mar 2026 11:25:47 -0300 Subject: [PATCH 04/12] fix(voip): update CallerInfo testID references in CallView tests Update 'caller-info' to 'caller-info-toggle' to match the testID rename from the CallerInfo toggle implementation. --- app/views/CallView/index.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/CallView/index.test.tsx b/app/views/CallView/index.test.tsx index edbd908843f..e2589a44ab3 100644 --- a/app/views/CallView/index.test.tsx +++ b/app/views/CallView/index.test.tsx @@ -91,7 +91,7 @@ describe('CallView', () => { ); - 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', () => { From ff3fcdcb7e9bea23011f35645cc22cd287df8e3f Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 31 Mar 2026 11:37:17 -0300 Subject: [PATCH 05/12] feat(voip): animate MediaCallHeader slide-up + fade on toggle Replace active-call View with Animated.View that slides up and fades out when controlsVisible is false. Animation only applies when focused (expanded call view); collapsed header bar remains always visible. --- .../MediaCallHeader/MediaCallHeader.test.tsx | 33 ++++++++ .../MediaCallHeader/MediaCallHeader.tsx | 20 ++++- .../MediaCallHeader.test.tsx.snap | 78 ++++++++++++++++--- 3 files changed, 115 insertions(+), 16 deletions(-) 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..718718c1a40 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,15 @@ 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 }) }] + })); const defaultHeaderStyle = { backgroundColor: colors.surfaceNeutral, @@ -36,13 +47,14 @@ 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..361d884d698 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`] = ` } > Date: Tue, 31 Mar 2026 11:43:58 -0300 Subject: [PATCH 06/12] feat(voip): auto-show controls on call state changes Show controls automatically when stateChange, trackStateChange events fire or when toggling focus, so users never miss important state updates. --- app/lib/services/voip/useCallStore.test.ts | 41 ++++++++++++++++++++-- app/lib/services/voip/useCallStore.ts | 7 ++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts index f66c41abe94..0bcb6ab3a66 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) => { + listeners[ev]?.forEach(fn => fn()); + }; + const call = { callId, state: 'active', muted: false, @@ -48,6 +51,7 @@ function createMockCall(callId: string): IClientMediaCall { accept: jest.fn(), reject: jest.fn() } as unknown as IClientMediaCall; + return { call, emit }; } describe('useCallStore controlsVisible', () => { @@ -74,6 +78,37 @@ describe('useCallStore controlsVisible', () => { 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); @@ -136,7 +171,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 657e68180e5..28b311769ff 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -163,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) { @@ -179,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 }); }; @@ -240,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 { From 8e27ba7ba94c2df1e982d9c0d291cc80fa5ea0fa Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 31 Mar 2026 11:48:09 -0300 Subject: [PATCH 07/12] chore: mark all controls animation slices as complete --- progress-controls-animation.md | 117 +++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 progress-controls-animation.md diff --git a/progress-controls-animation.md b/progress-controls-animation.md new file mode 100644 index 00000000000..1bf9e8bbebf --- /dev/null +++ b/progress-controls-animation.md @@ -0,0 +1,117 @@ +# Tap-to-Hide Controls in CallView — Progress + +## Vertical Slices + +Each slice is independently demoable and builds on the previous one. + +| Order | Title | Type | Blocked by | Status | +|-------|-------|------|------------|--------| +| 1 | feat: add `controlsVisible` state and actions to call store | AFK | None | [x] | +| 2 | feat: tap CallerInfo to toggle controls visibility | AFK | #1 | [x] | +| 3 | feat: animate CallButtons slide-down + fade on toggle | AFK | #2 | [x] | +| 4 | feat: animate MediaCallHeader slide-up + fade on toggle | AFK | #2 | [x] | +| 5 | feat: auto-show controls on call state changes | AFK | #1 | [x] | +| 6 | chore: update tests and snapshots for controls animation | AFK | #2, #3, #4 | [x] | + +--- + +### Slice 1: feat: add `controlsVisible` state and actions to call store + +**User stories:** 1, 2, 8 + +**What to do:** +- Add `controlsVisible: boolean` (default `true`) to `CallStoreState` in useCallStore +- Add `toggleControlsVisible()` action — flips the boolean +- Add `showControls()` action — sets `controlsVisible: true` +- Add `CONTROLS_ANIMATION_DURATION = 300` constant to CallView styles +- Add convenience selector `useControlsVisible` + +**Demo:** Call `toggleControlsVisible()` from store in tests/devtools and verify state flips. + +--- + +### Slice 2: feat: tap CallerInfo to toggle controls visibility + +**User stories:** 1, 2, 6, 7 + +**What to do:** +- Wrap CallerInfo outer `` with `` +- Wrap caller name `callerRow` in `` with fade + slight slide animation driven by `controlsVisible` +- Avatar stays unwrapped — always visible and centered +- Use `useAnimatedStyle` + `withTiming` reading `controlsVisible` directly from store (no useEffect) + +**Demo:** Tap the center of CallView — caller name fades out, avatar stays. Tap again — name fades back in. + +--- + +### Slice 3: feat: animate CallButtons slide-down + fade on toggle + +**User stories:** 1, 2, 5, 10 + +**What to do:** +- Replace outer `` in CallButtons with `` +- Add `useAnimatedStyle` for opacity (1→0) and translateY (0→100) driven by `controlsVisible` +- Set `pointerEvents={controlsVisible ? 'auto' : 'none'}` to block ghost taps + +**Demo:** Tap center — buttons slide down and fade out, invisible buttons are not tappable. Tap again — buttons slide back up. + +--- + +### Slice 4: feat: animate MediaCallHeader slide-up + fade on toggle + +**User stories:** 1, 2, 5, 11 + +**What to do:** +- Replace call-active `` in MediaCallHeader with `` +- Add `useAnimatedStyle` for opacity (1→0) and translateY (0→-100) driven by `controlsVisible` +- Guard: only animate when `focused === true`. When `focused === false` (collapsed header bar), always show. +- Set `pointerEvents={shouldHide ? 'none' : 'auto'}` + +**Demo:** Tap center in CallView — header slides up and disappears. Collapse to header bar — header is always visible regardless of `controlsVisible`. + +--- + +### Slice 5: feat: auto-show controls on call state changes + +**User stories:** 3, 4, 8, 9 + +**What to do:** +- In `handleStateChange` (inside `setCall`): add `set({ controlsVisible: true })` +- In `handleTrackStateChange` (inside `setCall`): add `set({ controlsVisible: true })` +- In `toggleFocus`: set `controlsVisible: true` when toggling (always reveal on focus change) +- `reset()` already covers call-end via `initialState` spread + +**Demo:** Hide controls → trigger remote hold from another client → controls auto-reveal. Collapse to header → re-expand → controls are visible. + +--- + +### Slice 6: chore: update tests and snapshots for controls animation + +**User stories:** All (verification) + +**What to do:** +- **Store tests:** `toggleControlsVisible` flips value, `showControls` sets true, auto-show on stateChange/trackStateChange, reset restores true, toggleFocus sets true +- **CallerInfo tests:** pressing `caller-info-toggle` calls `toggleControlsVisible`, snapshot update +- **CallButtons tests:** `pointerEvents='none'` when `controlsVisible=false`, snapshot update +- **MediaCallHeader tests:** `pointerEvents='none'` when `focused=true && controlsVisible=false`, `pointerEvents='auto'` when `focused=false`, snapshot update + +**Demo:** `yarn test -- --testPathPattern='CallView|MediaCallHeader|useCallStore'` passes. + +--- + +## Design Decisions Log + +| Question | Decision | +|----------|----------| +| What hides? | Everything except avatar — header, buttons, caller name/text | +| Auto-hide timer? | No — explicit tap only | +| Animation style? | Slide + fade, ~300ms with `withTiming` | +| Auto-show on state changes? | Yes — stateChange + trackStateChange events | +| New state or reuse `focused`? | New `controlsVisible` boolean, orthogonal to `focused` | +| MediaCallHeader location? | Stays at app root, subscribes to store independently | +| Tap target? | Center CallerInfo area only (not buttons) | + +## References + +- PRD: `prd-controls-animation.md` +- Plan: `.claude/plans/calm-brewing-fox.md` From 99a5b9a77abbe12d0e17eeb7b5b6bc7c9075719f Mon Sep 17 00:00:00 2001 From: diegolmello Date: Tue, 31 Mar 2026 14:55:55 +0000 Subject: [PATCH 08/12] chore: format code and fix lint issues --- .../MediaCallHeader/MediaCallHeader.tsx | 6 ++- app/views/CallView/components/CallButtons.tsx | 3 +- progress-controls-animation.md | 40 +++++++++++-------- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/app/containers/MediaCallHeader/MediaCallHeader.tsx b/app/containers/MediaCallHeader/MediaCallHeader.tsx index 718718c1a40..5dda99fd3af 100644 --- a/app/containers/MediaCallHeader/MediaCallHeader.tsx +++ b/app/containers/MediaCallHeader/MediaCallHeader.tsx @@ -48,7 +48,11 @@ const MediaCallHeader = () => { return ( diff --git a/app/views/CallView/components/CallButtons.tsx b/app/views/CallView/components/CallButtons.tsx index 10b89fe4886..0738906ed0a 100644 --- a/app/views/CallView/components/CallButtons.tsx +++ b/app/views/CallView/components/CallButtons.tsx @@ -52,8 +52,7 @@ export const CallButtons = () => { + testID='call-buttons'> ` with `` - Wrap caller name `callerRow` in `` with fade + slight slide animation driven by `controlsVisible` - Avatar stays unwrapped — always visible and centered @@ -49,6 +51,7 @@ Each slice is independently demoable and builds on the previous one. **User stories:** 1, 2, 5, 10 **What to do:** + - Replace outer `` in CallButtons with `` - Add `useAnimatedStyle` for opacity (1→0) and translateY (0→100) driven by `controlsVisible` - Set `pointerEvents={controlsVisible ? 'auto' : 'none'}` to block ghost taps @@ -62,6 +65,7 @@ Each slice is independently demoable and builds on the previous one. **User stories:** 1, 2, 5, 11 **What to do:** + - Replace call-active `` in MediaCallHeader with `` - Add `useAnimatedStyle` for opacity (1→0) and translateY (0→-100) driven by `controlsVisible` - Guard: only animate when `focused === true`. When `focused === false` (collapsed header bar), always show. @@ -76,6 +80,7 @@ Each slice is independently demoable and builds on the previous one. **User stories:** 3, 4, 8, 9 **What to do:** + - In `handleStateChange` (inside `setCall`): add `set({ controlsVisible: true })` - In `handleTrackStateChange` (inside `setCall`): add `set({ controlsVisible: true })` - In `toggleFocus`: set `controlsVisible: true` when toggling (always reveal on focus change) @@ -90,6 +95,7 @@ Each slice is independently demoable and builds on the previous one. **User stories:** All (verification) **What to do:** + - **Store tests:** `toggleControlsVisible` flips value, `showControls` sets true, auto-show on stateChange/trackStateChange, reset restores true, toggleFocus sets true - **CallerInfo tests:** pressing `caller-info-toggle` calls `toggleControlsVisible`, snapshot update - **CallButtons tests:** `pointerEvents='none'` when `controlsVisible=false`, snapshot update @@ -101,15 +107,15 @@ Each slice is independently demoable and builds on the previous one. ## Design Decisions Log -| Question | Decision | -|----------|----------| -| What hides? | Everything except avatar — header, buttons, caller name/text | -| Auto-hide timer? | No — explicit tap only | -| Animation style? | Slide + fade, ~300ms with `withTiming` | -| Auto-show on state changes? | Yes — stateChange + trackStateChange events | -| New state or reuse `focused`? | New `controlsVisible` boolean, orthogonal to `focused` | -| MediaCallHeader location? | Stays at app root, subscribes to store independently | -| Tap target? | Center CallerInfo area only (not buttons) | +| Question | Decision | +| ----------------------------- | ------------------------------------------------------------ | +| What hides? | Everything except avatar — header, buttons, caller name/text | +| Auto-hide timer? | No — explicit tap only | +| Animation style? | Slide + fade, ~300ms with `withTiming` | +| Auto-show on state changes? | Yes — stateChange + trackStateChange events | +| New state or reuse `focused`? | New `controlsVisible` boolean, orthogonal to `focused` | +| MediaCallHeader location? | Stays at app root, subscribes to store independently | +| Tap target? | Center CallerInfo area only (not buttons) | ## References From e6ee7ffeabf2b77c226cb24f299ce00a30899420 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 2 Apr 2026 11:36:28 -0300 Subject: [PATCH 09/12] refactor(voip): route force-show controls through showControls - Call showControls() from stateChange, trackStateChange, and toggleFocus instead of inlining controlsVisible in set() patches - Mock media emitter forwards variadic args in useCallStore tests - Add test and JSDoc for controls visibility / animation consumers Made-with: Cursor --- app/lib/services/voip/useCallStore.test.ts | 17 +++++++++++++++-- app/lib/services/voip/useCallStore.ts | 11 +++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts index 0bcb6ab3a66..8b137825b8b 100644 --- a/app/lib/services/voip/useCallStore.test.ts +++ b/app/lib/services/voip/useCallStore.test.ts @@ -30,8 +30,8 @@ function createMockCall(callId: string) { listeners[ev]?.delete(fn); } }; - const emit = (ev: string) => { - listeners[ev]?.forEach(fn => fn()); + const emit = (ev: string, ...args: unknown[]) => { + listeners[ev]?.forEach(fn => fn(...args)); }; const call = { callId, @@ -54,6 +54,19 @@ function createMockCall(callId: string) { 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(); diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 28b311769ff..5616e0347e4 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; + /** Zustand-driven for Call UI animations (subscribers re-render → `useAnimatedStyle`); see `decisions/call-view-controls-coderabbit.md`. */ controlsVisible: boolean; dialpadValue: string; @@ -163,7 +164,8 @@ export const useCallStore = create((set, get) => ({ if (!currentCall) return; const newState = currentCall.state; - set({ callState: newState, controlsVisible: true }); + set({ callState: newState }); + get().showControls(); // Set start time when call becomes active if (newState === 'active' && !get().callStartTime) { @@ -179,9 +181,9 @@ export const useCallStore = create((set, get) => ({ isMuted: currentCall.muted, isOnHold: currentCall.held, remoteMute: currentCall.remoteMute, - remoteHeld: currentCall.remoteHeld, - controlsVisible: true + remoteHeld: currentCall.remoteHeld }); + get().showControls(); }; const handleEnded = () => { @@ -241,7 +243,8 @@ export const useCallStore = create((set, get) => ({ toggleFocus: () => { const isFocused = get().focused; - set({ focused: !isFocused, controlsVisible: true }); + set({ focused: !isFocused }); + get().showControls(); if (isFocused) { Navigation.back(); } else { From 72540a9adc3f899468312c5e93535413238403d2 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 2 Apr 2026 14:04:56 -0300 Subject: [PATCH 10/12] Fix animation --- app/containers/MediaCallHeader/MediaCallHeader.tsx | 8 +++++++- app/lib/services/voip/MediaSessionInstance.test.ts | 1 - app/lib/services/voip/useCallStore.ts | 1 - 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/containers/MediaCallHeader/MediaCallHeader.tsx b/app/containers/MediaCallHeader/MediaCallHeader.tsx index 5dda99fd3af..5a0a848007e 100644 --- a/app/containers/MediaCallHeader/MediaCallHeader.tsx +++ b/app/containers/MediaCallHeader/MediaCallHeader.tsx @@ -34,7 +34,13 @@ const MediaCallHeader = () => { const animatedStyle = useAnimatedStyle(() => ({ opacity: withTiming(shouldHide ? 0 : 1, { duration: CONTROLS_ANIMATION_DURATION }), - transform: [{ translateY: withTiming(shouldHide ? -100 : 0, { duration: CONTROLS_ANIMATION_DURATION }) }] + transform: [ + { + translateY: withTiming(shouldHide ? -100 : 0, { + duration: CONTROLS_ANIMATION_DURATION + }) + } + ] })); const defaultHeaderStyle = { diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index 2418d2c82a0..4f5dcc513b9 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -59,7 +59,6 @@ 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') diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 5616e0347e4..5b2a1990d96 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -68,7 +68,6 @@ interface CallStoreState { isSpeakerOn: boolean; callStartTime: number | null; focused: boolean; - /** Zustand-driven for Call UI animations (subscribers re-render → `useAnimatedStyle`); see `decisions/call-view-controls-coderabbit.md`. */ controlsVisible: boolean; dialpadValue: string; From 1155e88bb4c5f8a997aad9d5b7f6e635ad640093 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 6 Apr 2026 14:43:56 -0300 Subject: [PATCH 11/12] fix(voip): animate header background to transparent when hiding controls The MediaCallHeader used translateY + opacity to hide, but its surfaceNeutral background remained visible as a dark bar. Animate backgroundColor and borderBottomColor to transparent so CallView shows through seamlessly. --- app/containers/MediaCallHeader/MediaCallHeader.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/containers/MediaCallHeader/MediaCallHeader.tsx b/app/containers/MediaCallHeader/MediaCallHeader.tsx index 5a0a848007e..f2e7471d08b 100644 --- a/app/containers/MediaCallHeader/MediaCallHeader.tsx +++ b/app/containers/MediaCallHeader/MediaCallHeader.tsx @@ -40,7 +40,9 @@ const MediaCallHeader = () => { 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 = { From a8ae63c10d175ba892c2ce8988d7bc65c4ce0744 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 6 Apr 2026 15:01:11 -0300 Subject: [PATCH 12/12] fix(voip): fold controlsVisible into set() calls, update snapshots, remove progress doc Merge controlsVisible: true into existing Zustand set() calls instead of separate showControls() to avoid double renders. Update stale MediaCallHeader snapshots and remove planning doc from repo. --- .../MediaCallHeader.test.tsx.snap | 12 ++ app/lib/services/voip/useCallStore.ts | 10 +- progress-controls-animation.md | 123 ------------------ 3 files changed, 16 insertions(+), 129 deletions(-) delete mode 100644 progress-controls-animation.md diff --git a/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap b/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap index 361d884d698..689dfc85e09 100644 --- a/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap +++ b/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap @@ -26,6 +26,8 @@ exports[`Story Snapshots: ActiveCall should match snapshot 1`] = ` "paddingTop": 12, }, { + "backgroundColor": "#E4E7EA", + "borderBottomColor": "#CBCED1", "opacity": 1, "transform": [ { @@ -382,6 +384,8 @@ exports[`Story Snapshots: Collapsed should match snapshot 1`] = ` "paddingTop": 12, }, { + "backgroundColor": "#E4E7EA", + "borderBottomColor": "#CBCED1", "opacity": 1, "transform": [ { @@ -738,6 +742,8 @@ exports[`Story Snapshots: ConnectingCall should match snapshot 1`] = ` "paddingTop": 12, }, { + "backgroundColor": "#E4E7EA", + "borderBottomColor": "#CBCED1", "opacity": 1, "transform": [ { @@ -1090,6 +1096,8 @@ exports[`Story Snapshots: Focused should match snapshot 1`] = ` "paddingTop": 12, }, { + "backgroundColor": "#E4E7EA", + "borderBottomColor": "#CBCED1", "opacity": 1, "transform": [ { @@ -1466,6 +1474,8 @@ exports[`Story Snapshots: WithRemoteHeld should match snapshot 1`] = ` "paddingTop": 12, }, { + "backgroundColor": "#E4E7EA", + "borderBottomColor": "#CBCED1", "opacity": 1, "transform": [ { @@ -1822,6 +1832,8 @@ exports[`Story Snapshots: WithRemoteMuted should match snapshot 1`] = ` "paddingTop": 12, }, { + "backgroundColor": "#E4E7EA", + "borderBottomColor": "#CBCED1", "opacity": 1, "transform": [ { diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 5b2a1990d96..28b311769ff 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -163,8 +163,7 @@ export const useCallStore = create((set, get) => ({ if (!currentCall) return; const newState = currentCall.state; - set({ callState: newState }); - get().showControls(); + set({ callState: newState, controlsVisible: true }); // Set start time when call becomes active if (newState === 'active' && !get().callStartTime) { @@ -180,9 +179,9 @@ export const useCallStore = create((set, get) => ({ isMuted: currentCall.muted, isOnHold: currentCall.held, remoteMute: currentCall.remoteMute, - remoteHeld: currentCall.remoteHeld + remoteHeld: currentCall.remoteHeld, + controlsVisible: true }); - get().showControls(); }; const handleEnded = () => { @@ -242,8 +241,7 @@ export const useCallStore = create((set, get) => ({ toggleFocus: () => { const isFocused = get().focused; - set({ focused: !isFocused }); - get().showControls(); + set({ focused: !isFocused, controlsVisible: true }); if (isFocused) { Navigation.back(); } else { diff --git a/progress-controls-animation.md b/progress-controls-animation.md deleted file mode 100644 index e126bb40b65..00000000000 --- a/progress-controls-animation.md +++ /dev/null @@ -1,123 +0,0 @@ -# Tap-to-Hide Controls in CallView — Progress - -## Vertical Slices - -Each slice is independently demoable and builds on the previous one. - -| Order | Title | Type | Blocked by | Status | -| ----- | ----------------------------------------------------------- | ---- | ---------- | ------ | -| 1 | feat: add `controlsVisible` state and actions to call store | AFK | None | [x] | -| 2 | feat: tap CallerInfo to toggle controls visibility | AFK | #1 | [x] | -| 3 | feat: animate CallButtons slide-down + fade on toggle | AFK | #2 | [x] | -| 4 | feat: animate MediaCallHeader slide-up + fade on toggle | AFK | #2 | [x] | -| 5 | feat: auto-show controls on call state changes | AFK | #1 | [x] | -| 6 | chore: update tests and snapshots for controls animation | AFK | #2, #3, #4 | [x] | - ---- - -### Slice 1: feat: add `controlsVisible` state and actions to call store - -**User stories:** 1, 2, 8 - -**What to do:** - -- Add `controlsVisible: boolean` (default `true`) to `CallStoreState` in useCallStore -- Add `toggleControlsVisible()` action — flips the boolean -- Add `showControls()` action — sets `controlsVisible: true` -- Add `CONTROLS_ANIMATION_DURATION = 300` constant to CallView styles -- Add convenience selector `useControlsVisible` - -**Demo:** Call `toggleControlsVisible()` from store in tests/devtools and verify state flips. - ---- - -### Slice 2: feat: tap CallerInfo to toggle controls visibility - -**User stories:** 1, 2, 6, 7 - -**What to do:** - -- Wrap CallerInfo outer `` with `` -- Wrap caller name `callerRow` in `` with fade + slight slide animation driven by `controlsVisible` -- Avatar stays unwrapped — always visible and centered -- Use `useAnimatedStyle` + `withTiming` reading `controlsVisible` directly from store (no useEffect) - -**Demo:** Tap the center of CallView — caller name fades out, avatar stays. Tap again — name fades back in. - ---- - -### Slice 3: feat: animate CallButtons slide-down + fade on toggle - -**User stories:** 1, 2, 5, 10 - -**What to do:** - -- Replace outer `` in CallButtons with `` -- Add `useAnimatedStyle` for opacity (1→0) and translateY (0→100) driven by `controlsVisible` -- Set `pointerEvents={controlsVisible ? 'auto' : 'none'}` to block ghost taps - -**Demo:** Tap center — buttons slide down and fade out, invisible buttons are not tappable. Tap again — buttons slide back up. - ---- - -### Slice 4: feat: animate MediaCallHeader slide-up + fade on toggle - -**User stories:** 1, 2, 5, 11 - -**What to do:** - -- Replace call-active `` in MediaCallHeader with `` -- Add `useAnimatedStyle` for opacity (1→0) and translateY (0→-100) driven by `controlsVisible` -- Guard: only animate when `focused === true`. When `focused === false` (collapsed header bar), always show. -- Set `pointerEvents={shouldHide ? 'none' : 'auto'}` - -**Demo:** Tap center in CallView — header slides up and disappears. Collapse to header bar — header is always visible regardless of `controlsVisible`. - ---- - -### Slice 5: feat: auto-show controls on call state changes - -**User stories:** 3, 4, 8, 9 - -**What to do:** - -- In `handleStateChange` (inside `setCall`): add `set({ controlsVisible: true })` -- In `handleTrackStateChange` (inside `setCall`): add `set({ controlsVisible: true })` -- In `toggleFocus`: set `controlsVisible: true` when toggling (always reveal on focus change) -- `reset()` already covers call-end via `initialState` spread - -**Demo:** Hide controls → trigger remote hold from another client → controls auto-reveal. Collapse to header → re-expand → controls are visible. - ---- - -### Slice 6: chore: update tests and snapshots for controls animation - -**User stories:** All (verification) - -**What to do:** - -- **Store tests:** `toggleControlsVisible` flips value, `showControls` sets true, auto-show on stateChange/trackStateChange, reset restores true, toggleFocus sets true -- **CallerInfo tests:** pressing `caller-info-toggle` calls `toggleControlsVisible`, snapshot update -- **CallButtons tests:** `pointerEvents='none'` when `controlsVisible=false`, snapshot update -- **MediaCallHeader tests:** `pointerEvents='none'` when `focused=true && controlsVisible=false`, `pointerEvents='auto'` when `focused=false`, snapshot update - -**Demo:** `yarn test -- --testPathPattern='CallView|MediaCallHeader|useCallStore'` passes. - ---- - -## Design Decisions Log - -| Question | Decision | -| ----------------------------- | ------------------------------------------------------------ | -| What hides? | Everything except avatar — header, buttons, caller name/text | -| Auto-hide timer? | No — explicit tap only | -| Animation style? | Slide + fade, ~300ms with `withTiming` | -| Auto-show on state changes? | Yes — stateChange + trackStateChange events | -| New state or reuse `focused`? | New `controlsVisible` boolean, orthogonal to `focused` | -| MediaCallHeader location? | Stays at app root, subscribes to store independently | -| Tap target? | Center CallerInfo area only (not buttons) | - -## References - -- PRD: `prd-controls-animation.md` -- Plan: `.claude/plans/calm-brewing-fox.md`