diff --git a/.gitignore b/.gitignore index f24d4a8f50d..abdf800e711 100644 --- a/.gitignore +++ b/.gitignore @@ -83,4 +83,12 @@ e2e/e2e_account.ts **/e2e_account.js **/e2e_account.ts -*.p8 \ No newline at end of file +*.p8 +.worktrees/ +.omc/ +.claude/ +.agents/ +skills-lock.json +CLAUDE.local.md +AGENTS.md +.superset/ \ No newline at end of file diff --git a/app/containers/NewMediaCall/FilterHeader.tsx b/app/containers/NewMediaCall/FilterHeader.tsx index ab86283d6d3..aac8b29018d 100644 --- a/app/containers/NewMediaCall/FilterHeader.tsx +++ b/app/containers/NewMediaCall/FilterHeader.tsx @@ -35,7 +35,6 @@ export const FilterHeader = (): React.ReactElement => { {}, + setHeld: () => {}, + hangup: () => {}, + reject: () => {}, + sendDTMF: () => {}, + emitter: { + on: () => {}, + off: () => {} + } + }; + + return mock as unknown as IClientMediaCall; +} + +/** + * Seed `useCallStore` with a mock call so `CallView` renders without going through `setCall` + * (which subscribes real listeners and starts `InCallManager`). + */ +export function seedMockCall(overrides: MockCallOverrides = {}): void { + const mockCall = createMockCall(overrides); + const callState: CallState = overrides.callState ?? 'active'; + + useCallStore.setState({ + call: mockCall, + callId: mockCall.callId, + callState, + isMuted: overrides.isMuted ?? false, + isOnHold: overrides.isOnHold ?? false, + isSpeakerOn: overrides.isSpeakerOn ?? false, + callStartTime: overrides.callStartTime ?? (callState === 'active' ? Date.now() : null), + contact: { ...DEFAULT_CONTACT, ...overrides.contact }, + roomId: overrides.roomId ?? 'mock-room-id', + focused: true, + controlsVisible: true + }); +} + +/** + * Dev helper: seed a mock call and navigate to `CallView`. Use from a debug button to exercise + * the call UI on the iOS simulator without a real call. + */ +export function launchMockCallView(overrides: MockCallOverrides = {}): void { + seedMockCall(overrides); + Navigation.navigate('CallView'); +} diff --git a/app/stacks/MasterDetailStack/index.tsx b/app/stacks/MasterDetailStack/index.tsx index d4dd3ccc2fa..db9a274cb55 100644 --- a/app/stacks/MasterDetailStack/index.tsx +++ b/app/stacks/MasterDetailStack/index.tsx @@ -54,6 +54,7 @@ import E2EEncryptionSecurityView from '../../views/E2EEncryptionSecurityView'; import AttachmentView from '../../views/AttachmentView'; import ModalBlockView from '../../views/ModalBlockView'; import JitsiMeetView from '../../views/JitsiMeetView'; +import CallView from '../../views/CallView'; import StatusView from '../../views/StatusView'; import CreateDiscussionView from '../../views/CreateDiscussionView'; import E2ESaveYourPasswordView from '../../views/E2ESaveYourPasswordView'; @@ -234,6 +235,7 @@ const InsideStackNavigator = React.memo(() => { /> {/* @ts-ignore */} + ); }); diff --git a/app/stacks/MasterDetailStack/types.ts b/app/stacks/MasterDetailStack/types.ts index 70f5726e6de..fb3e3619344 100644 --- a/app/stacks/MasterDetailStack/types.ts +++ b/app/stacks/MasterDetailStack/types.ts @@ -222,4 +222,5 @@ export type MasterDetailInsideStackParamList = { room: ISubscription; thread: any; // TODO: Change }; + CallView: undefined; }; diff --git a/app/views/CallView/CallView.stories.tsx b/app/views/CallView/CallView.stories.tsx index e4a4a18a5ac..ee045050d71 100644 --- a/app/views/CallView/CallView.stories.tsx +++ b/app/views/CallView/CallView.stories.tsx @@ -3,6 +3,10 @@ import { View, StyleSheet } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import CallView from '.'; +import CallerInfo from './components/CallerInfo'; +import { CallButtons } from './components/CallButtons'; +import { styles as callViewStyles } from './styles'; +import { useTheme } from '../../theme'; import { useCallStore } from '../../lib/services/voip/useCallStore'; import { BASE_ROW_HEIGHT, @@ -113,3 +117,48 @@ export const SpeakerOn = () => { setStoreState({ callState: 'active', isSpeakerOn: true }); return ; }; + +// Tablet / wide layout stories — force layoutMode='wide' via ResponsiveLayoutContext width +const TabletCallView = () => { + const { colors } = useTheme(); + const call = useCallStore(state => state.call); + if (!call) return null; + return ( + + + + + + + ); +}; + +export const TabletConnectedCall = () => { + setStoreState({ callState: 'active', callStartTime: mockCallStartTime - 61000 }); + return ; +}; + +export const TabletConnectingCall = () => { + setStoreState({ callState: 'accepted', callStartTime: null }); + return ; +}; + +export const TabletMutedCall = () => { + setStoreState({ callState: 'active', isMuted: true }); + return ; +}; + +export const TabletOnHoldCall = () => { + setStoreState({ callState: 'active', isOnHold: true }); + return ; +}; + +export const TabletMutedAndOnHold = () => { + setStoreState({ callState: 'active', isMuted: true, isOnHold: true }); + return ; +}; + +export const TabletSpeakerOn = () => { + setStoreState({ callState: 'active', isSpeakerOn: true }); + return ; +}; diff --git a/app/views/CallView/__snapshots__/index.test.tsx.snap b/app/views/CallView/__snapshots__/index.test.tsx.snap index 6518f575509..a6d960148e9 100644 --- a/app/views/CallView/__snapshots__/index.test.tsx.snap +++ b/app/views/CallView/__snapshots__/index.test.tsx.snap @@ -56,6 +56,7 @@ exports[`Story Snapshots: ConnectedCall should match snapshot 1`] = ` "alignItems": "center", "flex": 1, "justifyContent": "center", + "marginBottom": 100, "paddingHorizontal": 24, } } @@ -173,9 +174,16 @@ exports[`Story Snapshots: ConnectedCall should match snapshot 1`] = ` [ { "borderTopWidth": 0.5, + "bottom": 0, + "gap": 24, + "left": 0, "padding": 24, + "paddingBottom": 48, + "position": "absolute", + "right": 0, }, { + "backgroundColor": "#FFFFFF", "borderTopColor": "#EBECEF", }, { @@ -194,10 +202,11 @@ exports[`Story Snapshots: ConnectedCall should match snapshot 1`] = ` style={ { "flexDirection": "row", - "justifyContent": "space-around", - "marginBottom": 24, + "gap": 48, + "justifyContent": "center", } } + testID="call-buttons-row-0" > +  + + + + Dialpad + + + + + + +`; + +exports[`Story Snapshots: TabletConnectedCall should match snapshot 1`] = ` + + + + + + + + + + + Bob Burnquist + + + + + + + + +  + + + + Speaker + + + + + +  + + + + Hold + + + + + +  + + + + Mute + + + + + +  + + + + Message + + + + + +  + + + + End + + + + + +  + + + + Dialpad + + + + + + +`; + +exports[`Story Snapshots: TabletConnectingCall should match snapshot 1`] = ` + + + + + + + + + + + Bob Burnquist + + + + + + + + +  + + + + Speaker + + + + + +  + + + + Hold + + + + + +  + + + + Mute + + + + + +  + + + + Message + + + + + +  + + + + Cancel + + + + + +  + + + + Dialpad + + + + + + +`; + +exports[`Story Snapshots: TabletMutedAndOnHold should match snapshot 1`] = ` + + + + + + + + + + + Bob Burnquist + + + + + + + + +  + + + + Speaker + + + + + +  + + + + Unhold + + + + + +  + + + + Unmute + + + + + +  + + + + Message + + + + + +  + + + + End + + + + + +  + + + + Dialpad + + + + + + +`; + +exports[`Story Snapshots: TabletMutedCall should match snapshot 1`] = ` + + + + + + + + + + + Bob Burnquist + + + + + + + + +  + + + + Speaker + + + + + +  + + + + Hold + + + + + +  + + + + Unmute + + + + + +  + + + + Message + + + + + +  + + + + End + + + + + +  + + + + Dialpad + + + + + + +`; + +exports[`Story Snapshots: TabletOnHoldCall should match snapshot 1`] = ` + + + + + + + + + + + Bob Burnquist + + + + + + + + +  + + + + Speaker + + + + + +  + + + + Unhold + + + + + +  + + + + Mute + + + + + +  + + + + Message + + + + + +  + + + + End + + + + + +  + + + + Dialpad + + + + + + +`; + +exports[`Story Snapshots: TabletSpeakerOn should match snapshot 1`] = ` + + + + + + + + + + + Bob Burnquist + + + + + + + + +  + + + + Speaker + + + + + +  + + + + Hold + + + + + +  + + + + Mute + + + + + +  + + + + Message + + + + + +  + + + + End + + + + + { + const isLargeFontScale = fontScale > FONT_SCALE_LIMIT; + const fontScaleLimited = isLargeFontScale ? FONT_SCALE_LIMIT : fontScale; + + return { + fontScale, + fontScaleLimited, + isLargeFontScale, + rowHeight: BASE_ROW_HEIGHT * fontScale, + rowHeightCondensed: BASE_ROW_HEIGHT_CONDENSED * fontScale, + width: 350, + height: 800 + }; +}; + const Wrapper = ({ children }: { children: React.ReactNode }) => {children}; export default { - title: 'CallActionButton', - component: CallActionButton + title: 'CallView/CallActionButton', + component: CallActionButton, + decorators: [ + (Story: React.ComponentType) => ( + + + + + + ) + ] }; export const DefaultButton = () => ( @@ -59,3 +94,18 @@ export const AllVariants = () => ( ); + +// Tablet / wide layout: all action buttons in a single row, mirroring +// CallButtons rendering when layoutMode='wide'. +export const TabletAllVariants = () => ( + + + {}} testID='speaker' /> + {}} testID='hold' /> + {}} testID='mute' /> + {}} testID='message' /> + {}} variant='danger' testID='end' /> + {}} testID='dialpad' /> + + +); diff --git a/app/views/CallView/components/CallButtons.test.tsx b/app/views/CallView/components/CallButtons.test.tsx index 1cc888ba70e..9515f02a9cb 100644 --- a/app/views/CallView/components/CallButtons.test.tsx +++ b/app/views/CallView/components/CallButtons.test.tsx @@ -1,19 +1,27 @@ import React from 'react'; -import { fireEvent, render } from '@testing-library/react-native'; +import { fireEvent, render, within } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import { mockedStore } from '../../../reducers/mockedStore'; import { useCallStore } from '../../../lib/services/voip/useCallStore'; import { navigateToCallRoom } from '../../../lib/services/voip/navigateToCallRoom'; import { CallButtons } from './CallButtons'; +import { useCallLayoutMode } from '../useCallLayoutMode'; jest.mock('../../../lib/services/voip/navigateToCallRoom', () => ({ navigateToCallRoom: jest.fn().mockResolvedValue(undefined) })); +jest.mock('../useCallLayoutMode', () => ({ + useCallLayoutMode: jest.fn(() => ({ layoutMode: 'narrow' })) +})); + +const mockUseCallLayoutMode = jest.mocked(useCallLayoutMode); + +const mockShowActionSheetRef = jest.fn(); jest.mock('../../../containers/ActionSheet', () => ({ - ...jest.requireActual('../../../containers/ActionSheet'), - showActionSheetRef: jest.fn() + showActionSheetRef: (options: any) => mockShowActionSheetRef(options), + hideActionSheetRef: jest.fn() })); const mockNavigateToCallRoom = jest.mocked(navigateToCallRoom); @@ -24,6 +32,7 @@ describe('CallButtons', () => { beforeEach(() => { useCallStore.getState().reset(); jest.clearAllMocks(); + mockUseCallLayoutMode.mockReturnValue({ layoutMode: 'narrow' }); useCallStore.setState({ call: { state: 'active', contact: {} } as any, callState: 'active', @@ -97,4 +106,86 @@ describe('CallButtons', () => { fireEvent.press(getByTestId('call-view-message')); expect(mockNavigateToCallRoom).not.toHaveBeenCalled(); }); + + describe('layoutMode prop', () => { + it('renders two button rows on narrow layout', () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId('call-buttons-row-0')).toBeTruthy(); + expect(getByTestId('call-buttons-row-1')).toBeTruthy(); + }); + + it('renders a single button row on wide layout', () => { + mockUseCallLayoutMode.mockReturnValue({ layoutMode: 'wide' }); + const { getByTestId, queryByTestId } = render( + + + + ); + expect(getByTestId('call-buttons-row-0')).toBeTruthy(); + expect(queryByTestId('call-buttons-row-1')).toBeNull(); + }); + + it('renders all six action buttons regardless of layoutMode', () => { + const ids = [ + 'call-view-speaker', + 'call-view-hold', + 'call-view-mute', + 'call-view-message', + 'call-view-end', + 'call-view-dialpad' + ]; + (['narrow', 'wide'] as const).forEach(layoutMode => { + mockUseCallLayoutMode.mockReturnValue({ layoutMode }); + const { getByTestId, unmount } = render( + + + + ); + ids.forEach(id => expect(getByTestId(id)).toBeTruthy()); + unmount(); + }); + }); + + it('places every action button inside row 0 on wide layout', () => { + mockUseCallLayoutMode.mockReturnValue({ layoutMode: 'wide' }); + const { getByTestId } = render( + + + + ); + const row0 = getByTestId('call-buttons-row-0'); + const ids = [ + 'call-view-speaker', + 'call-view-hold', + 'call-view-mute', + 'call-view-message', + 'call-view-end', + 'call-view-dialpad' + ]; + ids.forEach(id => { + expect(within(row0).getByTestId(id)).toBeTruthy(); + }); + }); + + it('splits buttons across row 0 and row 1 on narrow layout', () => { + const { getByTestId } = render( + + + + ); + const row0 = getByTestId('call-buttons-row-0'); + const row1 = getByTestId('call-buttons-row-1'); + + expect(within(row0).getByTestId('call-view-speaker')).toBeTruthy(); + expect(within(row0).getByTestId('call-view-hold')).toBeTruthy(); + expect(within(row0).getByTestId('call-view-mute')).toBeTruthy(); + expect(within(row1).getByTestId('call-view-message')).toBeTruthy(); + expect(within(row1).getByTestId('call-view-end')).toBeTruthy(); + expect(within(row1).getByTestId('call-view-dialpad')).toBeTruthy(); + }); + }); }); diff --git a/app/views/CallView/components/CallButtons.tsx b/app/views/CallView/components/CallButtons.tsx index ffeeff62479..332b123a9c5 100644 --- a/app/views/CallView/components/CallButtons.tsx +++ b/app/views/CallView/components/CallButtons.tsx @@ -10,11 +10,23 @@ import { CONTROLS_ANIMATION_DURATION, styles } from '../styles'; import { useTheme } from '../../../theme'; import { showActionSheetRef } from '../../../containers/ActionSheet'; import Dialpad from './Dialpad/Dialpad'; +import { useCallLayoutMode } from '../useCallLayoutMode'; +import { type TIconsName } from '../../../containers/CustomIcon'; + +interface ICallButtonConfig { + testID: string; + icon: TIconsName; + label: string; + onPress: () => void; + variant?: 'default' | 'active' | 'danger'; + disabled: boolean; +} export const CallButtons = () => { 'use memo'; const { colors } = useTheme(); + const { layoutMode } = useCallLayoutMode(); const callState = useCallStore(state => state.callState); const isMuted = useCallStore(state => state.isMuted); @@ -50,61 +62,108 @@ export const CallButtons = () => { endCall(); }; + const buttons: ICallButtonConfig[] = [ + { + testID: 'call-view-speaker', + icon: isSpeakerOn ? 'audio' : 'audio-disabled', + label: I18n.t('Speaker'), + onPress: toggleSpeaker, + variant: isSpeakerOn ? 'active' : 'default', + disabled: isConnecting + }, + { + testID: 'call-view-hold', + icon: 'pause-shape-unfilled', + label: isOnHold ? I18n.t('Unhold') : I18n.t('Hold'), + onPress: toggleHold, + variant: isOnHold ? 'active' : 'default', + disabled: isConnecting + }, + { + testID: 'call-view-mute', + icon: isMuted ? 'microphone-disabled' : 'microphone', + label: isMuted ? I18n.t('Unmute') : I18n.t('Mute'), + onPress: toggleMute, + variant: isMuted ? 'active' : 'default', + disabled: isConnecting + }, + { + testID: 'call-view-message', + icon: 'message', + label: I18n.t('Message'), + onPress: handleMessage, + disabled: messageDisabled + }, + { + testID: 'call-view-end', + icon: 'phone-off', + label: isConnecting ? I18n.t('Cancel') : I18n.t('End'), + onPress: handleEndCall, + variant: 'danger', + disabled: false + }, + { + testID: 'call-view-dialpad', + icon: 'dialpad', + label: I18n.t('Dialpad'), + onPress: handleDialpad, + disabled: isConnecting + } + ]; + return ( - - - - - - - - - - - + {layoutMode === 'wide' ? ( + + {buttons.map(btn => ( + + ))} + + ) : ( + <> + + {buttons.slice(0, 3).map(btn => ( + + ))} + + + {buttons.slice(3, 6).map(btn => ( + + ))} + + + )} ); }; diff --git a/app/views/CallView/components/CallerInfo.stories.tsx b/app/views/CallView/components/CallerInfo.stories.tsx index c554a4fa253..a9dd059946c 100644 --- a/app/views/CallView/components/CallerInfo.stories.tsx +++ b/app/views/CallView/components/CallerInfo.stories.tsx @@ -28,7 +28,7 @@ const setStoreState = (contact: { displayName?: string; username?: string; sipEx }; export default { - title: 'CallerInfo', + title: 'CallView/CallerInfo', component: CallerInfo, decorators: [ (Story: React.ComponentType) => { diff --git a/app/views/CallView/components/Dialpad/Dialpad.stories.tsx b/app/views/CallView/components/Dialpad/Dialpad.stories.tsx index e17db7cd370..c8c242f9d69 100644 --- a/app/views/CallView/components/Dialpad/Dialpad.stories.tsx +++ b/app/views/CallView/components/Dialpad/Dialpad.stories.tsx @@ -3,17 +3,43 @@ import { View, StyleSheet } from 'react-native'; import Dialpad from './Dialpad'; import { useCallStore } from '../../../../lib/services/voip/useCallStore'; +import { + ResponsiveLayoutContext, + BASE_ROW_HEIGHT, + BASE_ROW_HEIGHT_CONDENSED +} from '../../../../lib/hooks/useResponsiveLayout/useResponsiveLayout'; const styles = StyleSheet.create({ container: { padding: 24, - alignItems: 'center', - minHeight: 500 + width: 500 + }, + landscapeContainer: { + width: 700 } }); const Wrapper = ({ children }: { children: React.ReactNode }) => {children}; +// Provides a ResponsiveLayoutContext with a fixed width so useCallLayoutMode +// returns a deterministic layoutMode in both Jest and the Storybook UI. +// width=350 → narrow (< MIN_WIDTH_MASTER_DETAIL_LAYOUT=700) +// width=800 → wide (≥ MIN_WIDTH_MASTER_DETAIL_LAYOUT=700) +const LayoutWrapper = ({ width, children }: { width: number; children: React.ReactNode }) => ( + + {children} + +); + // Helper to set store state for stories - call with sendDTMF so dialpad buttons work const setStoreState = (overrides: Partial> = {}) => { const mockCall = { @@ -35,7 +61,7 @@ const setStoreState = (overrides: Partial { @@ -49,9 +75,40 @@ export default { ] }; -export const Default = () => ; +export const Default = () => ( + + + +); export const WithValue = () => { setStoreState({ dialpadValue: '123' }); - return ; + return ( + + + + ); +}; + +const LandscapeWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +export const TabletLandscape = () => ( + + + + + +); + +export const TabletLandscapeWithValue = () => { + setStoreState({ dialpadValue: '1234' }); + return ( + + + + + + ); }; diff --git a/app/views/CallView/components/Dialpad/Dialpad.test.tsx b/app/views/CallView/components/Dialpad/Dialpad.test.tsx index 9a10d799cb3..5e214eae2a9 100644 --- a/app/views/CallView/components/Dialpad/Dialpad.test.tsx +++ b/app/views/CallView/components/Dialpad/Dialpad.test.tsx @@ -7,6 +7,21 @@ import { useCallStore } from '../../../../lib/services/voip/useCallStore'; import { mockedStore } from '../../../../reducers/mockedStore'; import * as stories from './Dialpad.stories'; import { generateSnapshots } from '../../../../../.rnstorybook/generateSnapshots'; +import { useCallLayoutMode } from '../../useCallLayoutMode'; + +jest.mock('../../useCallLayoutMode', () => ({ + useCallLayoutMode: jest.fn(() => ({ layoutMode: 'narrow' })) +})); + +jest.mock('../../../../containers/ActionSheet', () => ({ + hideActionSheetRef: jest.fn() +})); + +jest.mock('react-native-incall-manager', () => ({ + start: jest.fn(), + stop: jest.fn(), + setForceSpeakerphoneOn: jest.fn(() => Promise.resolve()) +})); const sendDTMFMock = jest.fn(); @@ -34,6 +49,7 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => { beforeEach(() => { + (useCallLayoutMode as jest.Mock).mockReturnValue({ layoutMode: 'narrow' }); useCallStore.getState().reset(); sendDTMFMock.mockClear(); }); @@ -110,6 +126,29 @@ describe('Dialpad', () => { ); expect(getByTestId('custom-dialpad-input')).toBeTruthy(); }); + + it('should render in landscape layout when layoutMode is wide', () => { + (useCallLayoutMode as jest.Mock).mockReturnValue({ layoutMode: 'wide' }); + setStoreState(); + const { getByTestId } = render( + + + + ); + expect(getByTestId('dialpad-landscape-container')).toBeTruthy(); + }); + + it('should render in portrait layout when layoutMode is narrow', () => { + (useCallLayoutMode as jest.Mock).mockReturnValue({ layoutMode: 'narrow' }); + setStoreState(); + const { getByTestId, queryByTestId } = render( + + + + ); + expect(getByTestId('dialpad-input')).toBeTruthy(); + expect(queryByTestId('dialpad-landscape-container')).toBeNull(); + }); }); generateSnapshots(stories); diff --git a/app/views/CallView/components/Dialpad/Dialpad.tsx b/app/views/CallView/components/Dialpad/Dialpad.tsx index 6b1a9065258..3c77d524c0c 100644 --- a/app/views/CallView/components/Dialpad/Dialpad.tsx +++ b/app/views/CallView/components/Dialpad/Dialpad.tsx @@ -1,11 +1,12 @@ import React from 'react'; import { View } from 'react-native'; -import { useDialpadValue } from '../../../../lib/services/voip/useCallStore'; import { FormTextInput } from '../../../../containers/TextInput'; -import { useTheme } from '../../../../theme'; import { styles } from './styles'; import DialpadButton from './DialpadButton'; +import { useCallLayoutMode } from '../../useCallLayoutMode'; +import { useDialpadValue } from '../../../../lib/services/voip/useCallStore'; +import { useTheme } from '../../../../theme'; const DIALPAD_KEYS: { digit: string; letters: string }[][] = [ [ @@ -34,32 +35,51 @@ interface IDialpad { testID?: string; } +export const DialpadGrid = (): React.ReactElement => ( + + {DIALPAD_KEYS.map((row, rowIndex) => ( + + {row.map(({ digit, letters }) => ( + + ))} + + ))} + +); + const Dialpad = ({ testID }: IDialpad): React.ReactElement => { + const { layoutMode } = useCallLayoutMode(); const { colors } = useTheme(); const dialpadValue = useDialpadValue(); + const input = ( + + ); + + if (layoutMode === 'wide') { + return ( + + {input} + + + + + ); + } + return ( - - - {DIALPAD_KEYS.map((row, rowIndex) => ( - - {row.map(({ digit, letters }) => ( - - ))} - - ))} - + {input} + ); }; diff --git a/app/views/CallView/components/Dialpad/__snapshots__/Dialpad.test.tsx.snap b/app/views/CallView/components/Dialpad/__snapshots__/Dialpad.test.tsx.snap index 5b48701b66a..d042cf5898d 100644 --- a/app/views/CallView/components/Dialpad/__snapshots__/Dialpad.test.tsx.snap +++ b/app/views/CallView/components/Dialpad/__snapshots__/Dialpad.test.tsx.snap @@ -4,9 +4,8 @@ exports[`Story Snapshots: Default should match snapshot 1`] = ` @@ -24,13 +23,13 @@ exports[`Story Snapshots: Default should match snapshot 1`] = ` } > `; +exports[`Story Snapshots: TabletLandscape should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + 1 + + + + + + + + 2 + + + ABC + + + + + + + 3 + + + DEF + + + + + + + + + 4 + + + GHI + + + + + + + 5 + + + JKL + + + + + + + 6 + + + MNO + + + + + + + + + 7 + + + PQRS + + + + + + + 8 + + + TUV + + + + + + + 9 + + + WXYZ + + + + + + + + + * + + + + + + + + 0 + + + + + + + + + + + # + + + + + + + + + +`; + +exports[`Story Snapshots: TabletLandscapeWithValue should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + 1 + + + + + + + + 2 + + + ABC + + + + + + + 3 + + + DEF + + + + + + + + + 4 + + + GHI + + + + + + + 5 + + + JKL + + + + + + + 6 + + + MNO + + + + + + + + + 7 + + + PQRS + + + + + + + 8 + + + TUV + + + + + + + 9 + + + WXYZ + + + + + + + + + * + + + + + + + + 0 + + + + + + + + + + + # + + + + + + + + + +`; + exports[`Story Snapshots: WithValue should match snapshot 1`] = ` @@ -1283,13 +3840,13 @@ exports[`Story Snapshots: WithValue should match snapshot 1`] = ` } > - - - -  - - - - Unmute - - - -`; - -exports[`Story Snapshots: AllVariants should match snapshot 1`] = ` -  +  - Speaker + Unmute - - + +`; + +exports[`Story Snapshots: AllVariants should match snapshot 1`] = ` + + + + + - + +  + + + -  + Speaker - + - Hold - - - - - + +  + + + -  + Hold - + - Mute - + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + [ + { + "alignItems": "center", + "borderRadius": 8, + "height": 64, + "justifyContent": "center", + "marginBottom": 8, + "width": 64, + }, + false, + { + "backgroundColor": "#E4E7EA", + }, + ] + } + testID="mute" + > + +  + + + + Mute + + + + + + + +  + + + + Message + + + + + +  + + + + End + + + + + +  + + + + More + + + +`; + +exports[`Story Snapshots: DangerButton should match snapshot 1`] = ` + -  +  - Message + End + + +`; + +exports[`Story Snapshots: DefaultButton should match snapshot 1`] = ` + + -  +  - End + Mute + + +`; + +exports[`Story Snapshots: DisabledButton should match snapshot 1`] = ` + + -  +  - More + Hold `; -exports[`Story Snapshots: DangerButton should match snapshot 1`] = ` +exports[`Story Snapshots: TabletAllVariants should match snapshot 1`] = ` - - + - + -  - - - - End - - - -`; - -exports[`Story Snapshots: DefaultButton should match snapshot 1`] = ` - - - - + +  + + + + Speaker + + + + + +  + + + + Hold + + + + -  - - - - Mute - - - -`; - -exports[`Story Snapshots: DisabledButton should match snapshot 1`] = ` - - - - + +  + + + + Mute + + + + -  - + "busy": undefined, + "checked": undefined, + "disabled": false, + "expanded": undefined, + "selected": undefined, + } + } + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + [ + { + "alignItems": "center", + "borderRadius": 8, + "height": 64, + "justifyContent": "center", + "marginBottom": 8, + "width": 64, + }, + false, + { + "backgroundColor": "#E4E7EA", + }, + ] + } + testID="message" + > + +  + + + + Message + + + + + +  + + + + End + + + + + +  + + + + Dialpad + + - - Hold - `; diff --git a/app/views/CallView/components/__snapshots__/CallerInfo.test.tsx.snap b/app/views/CallView/components/__snapshots__/CallerInfo.test.tsx.snap index 097b492a88b..17ba4213f33 100644 --- a/app/views/CallView/components/__snapshots__/CallerInfo.test.tsx.snap +++ b/app/views/CallView/components/__snapshots__/CallerInfo.test.tsx.snap @@ -45,6 +45,7 @@ exports[`Story Snapshots: Default should match snapshot 1`] = ` "alignItems": "center", "flex": 1, "justifyContent": "center", + "marginBottom": 100, "paddingHorizontal": 24, } } @@ -204,6 +205,7 @@ exports[`Story Snapshots: UsernameOnly should match snapshot 1`] = ` "alignItems": "center", "flex": 1, "justifyContent": "center", + "marginBottom": 100, "paddingHorizontal": 24, } } diff --git a/app/views/CallView/index.test.tsx b/app/views/CallView/index.test.tsx index dc08c12ddf7..64ddb7f5e42 100644 --- a/app/views/CallView/index.test.tsx +++ b/app/views/CallView/index.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent, render } from '@testing-library/react-native'; +import { fireEvent, render, within } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import CallView from '.'; @@ -9,34 +9,52 @@ import { mockedStore } from '../../reducers/mockedStore'; import * as stories from './CallView.stories'; import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots'; +let mockWindowWidth = 350; +jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ + __esModule: true, + default: () => ({ width: mockWindowWidth, height: 800, scale: 1, fontScale: 1 }) +})); + const mockNavigateToCallRoom = jest.mocked(navigateToCallRoom); jest.mock('../../lib/services/voip/navigateToCallRoom', () => ({ navigateToCallRoom: jest.fn().mockResolvedValue(undefined) })); -// Mock ResponsiveLayoutContext for snapshots +// Mock useResponsiveLayout so its width tracks mockWindowWidth dynamically. +// Honors an explicit ResponsiveLayoutContext.Provider (e.g. TabletCallView story +// forcing width=800) so stories can drive layoutMode without a prop. jest.mock('../../lib/hooks/useResponsiveLayout/useResponsiveLayout', () => { - const React = require('react'); const actual = jest.requireActual('../../lib/hooks/useResponsiveLayout/useResponsiveLayout'); + const React = require('react'); + const { useWindowDimensions } = require('react-native'); return { ...actual, - ResponsiveLayoutContext: React.createContext({ - fontScale: 1, - width: 350, - height: 800, - isLargeFontScale: false, - fontScaleLimited: 1, - rowHeight: 75, - rowHeightCondensed: 60 - }) + useResponsiveLayout: () => { + const ctx = React.useContext(actual.ResponsiveLayoutContext); + const { width: winWidth, height: winHeight, fontScale: winFontScale } = useWindowDimensions(); + const width = ctx && ctx.width ? ctx.width : winWidth; + const height = ctx && ctx.height ? ctx.height : winHeight; + const fontScale = ctx && ctx.fontScale ? ctx.fontScale : winFontScale; + const isLargeFontScale = fontScale > actual.FONT_SCALE_LIMIT; + const fontScaleLimited = isLargeFontScale ? actual.FONT_SCALE_LIMIT : fontScale; + return { + fontScale, + width, + height, + isLargeFontScale, + fontScaleLimited, + rowHeight: actual.BASE_ROW_HEIGHT * fontScale, + rowHeightCondensed: actual.BASE_ROW_HEIGHT_CONDENSED * fontScale + }; + } }; }); const mockShowActionSheetRef = jest.fn(); jest.mock('../../containers/ActionSheet', () => ({ - ...jest.requireActual('../../containers/ActionSheet'), - showActionSheetRef: (options: any) => mockShowActionSheetRef(options) + showActionSheetRef: (options: any) => mockShowActionSheetRef(options), + hideActionSheetRef: jest.fn() })); // Helper to create a mock call @@ -83,8 +101,9 @@ const setStoreState = (overrides: Partial {children}; -describe('CallView', () => { +describe('CallView/CallView', () => { beforeEach(() => { + mockWindowWidth = 350; useCallStore.getState().reset(); jest.clearAllMocks(); }); @@ -392,6 +411,113 @@ describe('CallView', () => { expect(getByText('Unmute')).toBeTruthy(); }); + + it('should render buttons in two rows on narrow layout', () => { + mockWindowWidth = 350; + setStoreState(); + const { getByTestId } = render( + + + + ); + expect(getByTestId('call-buttons-row-0')).toBeTruthy(); + expect(getByTestId('call-buttons-row-1')).toBeTruthy(); + }); + + it('should render buttons in a single row on wide layout', () => { + mockWindowWidth = 800; + setStoreState(); + const { getByTestId, queryByTestId } = render( + + + + ); + expect(getByTestId('call-buttons-row-0')).toBeTruthy(); + expect(queryByTestId('call-buttons-row-1')).toBeNull(); + }); +}); + +describe('CallView (tablet/wide layout)', () => { + beforeEach(() => { + mockWindowWidth = 800; + useCallStore.getState().reset(); + jest.clearAllMocks(); + }); + + afterAll(() => { + mockWindowWidth = 350; + }); + + it('renders all six action buttons in a single row', () => { + setStoreState(); + const { getByTestId, queryByTestId } = render( + + + + ); + + expect(getByTestId('call-buttons-row-0')).toBeTruthy(); + expect(queryByTestId('call-buttons-row-1')).toBeNull(); + expect(getByTestId('call-view-speaker')).toBeTruthy(); + expect(getByTestId('call-view-hold')).toBeTruthy(); + expect(getByTestId('call-view-mute')).toBeTruthy(); + expect(getByTestId('call-view-message')).toBeTruthy(); + expect(getByTestId('call-view-end')).toBeTruthy(); + expect(getByTestId('call-view-dialpad')).toBeTruthy(); + }); + + it('places every action button inside row 0', () => { + setStoreState(); + const { getByTestId } = render( + + + + ); + + const row0 = getByTestId('call-buttons-row-0'); + const ids = [ + 'call-view-speaker', + 'call-view-hold', + 'call-view-mute', + 'call-view-message', + 'call-view-end', + 'call-view-dialpad' + ]; + ids.forEach(id => { + expect(within(row0).getByTestId(id)).toBeTruthy(); + }); + }); + + it('returns null when there is no call on wide layout', () => { + useCallStore.setState({ call: null }); + const { queryByTestId } = render( + + + + ); + expect(queryByTestId('call-buttons-row-0')).toBeNull(); + }); + + it('disables action buttons while connecting on wide layout', () => { + const toggleHold = jest.fn(); + const toggleMute = jest.fn(); + const toggleSpeaker = jest.fn(); + setStoreState({ callState: 'ringing' }); + useCallStore.setState({ toggleHold, toggleMute, toggleSpeaker }); + + const { getByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('call-view-hold')); + fireEvent.press(getByTestId('call-view-mute')); + fireEvent.press(getByTestId('call-view-speaker')); + expect(toggleHold).not.toHaveBeenCalled(); + expect(toggleMute).not.toHaveBeenCalled(); + expect(toggleSpeaker).not.toHaveBeenCalled(); + }); }); generateSnapshots(stories); diff --git a/app/views/CallView/styles.ts b/app/views/CallView/styles.ts index d8eb8b74b0d..fe2da8c513f 100644 --- a/app/views/CallView/styles.ts +++ b/app/views/CallView/styles.ts @@ -17,7 +17,8 @@ export const styles = StyleSheet.create({ flex: 1, alignItems: 'center', justifyContent: 'center', - paddingHorizontal: 24 + paddingHorizontal: 24, + marginBottom: 100 }, avatarContainer: { marginBottom: 16, @@ -53,12 +54,18 @@ export const styles = StyleSheet.create({ }, buttonsContainer: { padding: 24, - borderTopWidth: StyleSheet.hairlineWidth + paddingBottom: 48, + gap: 24, + borderTopWidth: StyleSheet.hairlineWidth, + position: 'absolute', + bottom: 0, + left: 0, + right: 0 }, buttonsRow: { flexDirection: 'row', - justifyContent: 'space-around', - marginBottom: 24 + justifyContent: 'center', + gap: 48 }, actionButton: { alignItems: 'center', diff --git a/app/views/CallView/types.ts b/app/views/CallView/types.ts new file mode 100644 index 00000000000..2d1f32d240d --- /dev/null +++ b/app/views/CallView/types.ts @@ -0,0 +1 @@ +export type LayoutMode = 'narrow' | 'wide'; diff --git a/app/views/CallView/useCallLayoutMode.ts b/app/views/CallView/useCallLayoutMode.ts new file mode 100644 index 00000000000..5126f08c3f6 --- /dev/null +++ b/app/views/CallView/useCallLayoutMode.ts @@ -0,0 +1,8 @@ +import { useResponsiveLayout } from '../../lib/hooks/useResponsiveLayout/useResponsiveLayout'; +import { MIN_WIDTH_MASTER_DETAIL_LAYOUT } from '../../lib/constants/tablet'; +import { type LayoutMode } from './types'; + +export const useCallLayoutMode = (): { layoutMode: LayoutMode } => { + const { width } = useResponsiveLayout(); + return { layoutMode: width >= MIN_WIDTH_MASTER_DETAIL_LAYOUT ? 'wide' : 'narrow' }; +}; diff --git a/app/views/RoomsListView/components/ListHeader.tsx b/app/views/RoomsListView/components/ListHeader.tsx index 5e3870175e2..08459acd70d 100644 --- a/app/views/RoomsListView/components/ListHeader.tsx +++ b/app/views/RoomsListView/components/ListHeader.tsx @@ -7,6 +7,7 @@ import { E2E_BANNER_TYPE } from '../../../lib/constants/keys'; import { themes } from '../../../lib/constants/colors'; import { useAppSelector } from '../../../lib/hooks/useAppSelector'; import { events, logEvent } from '../../../lib/methods/helpers/log'; +import { launchMockCallView } from '../../../lib/services/voip/mockCall'; import { useTheme } from '../../../theme'; import { RoomsSearchContext } from '../contexts/RoomsSearchProvider'; @@ -39,6 +40,18 @@ const ListHeader = () => { return ( <> + {__DEV__ ? ( + <> + } + onPress={() => launchMockCallView()} + testID='listheader-mock-call' + translateTitle={false} + /> + + + ) : null} {encryptionBanner ? ( <>