diff --git a/app/containers/Avatar/__snapshots__/Avatar.test.tsx.snap b/app/containers/Avatar/__snapshots__/Avatar.test.tsx.snap index 97d9de5ea35..44cdc296b6f 100644 --- a/app/containers/Avatar/__snapshots__/Avatar.test.tsx.snap +++ b/app/containers/Avatar/__snapshots__/Avatar.test.tsx.snap @@ -398,6 +398,7 @@ exports[`Story Snapshots: Children should match snapshot 1`] = ` [ { "height": 24, + "textAlign": "center", "textAlignVertical": "center", "width": 24, }, diff --git a/app/containers/Header/components/HeaderButton/__snapshots__/HeaderButtons.test.tsx.snap b/app/containers/Header/components/HeaderButton/__snapshots__/HeaderButtons.test.tsx.snap index 938b20fc972..1c917ed5c7d 100644 --- a/app/containers/Header/components/HeaderButton/__snapshots__/HeaderButtons.test.tsx.snap +++ b/app/containers/Header/components/HeaderButton/__snapshots__/HeaderButtons.test.tsx.snap @@ -3923,7 +3923,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 24, }, [ @@ -4012,7 +4012,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 24, }, [ @@ -4164,7 +4164,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` "textAlign": "left", }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -4219,7 +4219,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 24, }, [ diff --git a/app/containers/List/__snapshots__/List.test.tsx.snap b/app/containers/List/__snapshots__/List.test.tsx.snap index 6414017d4fd..5fdf697d887 100644 --- a/app/containers/List/__snapshots__/List.test.tsx.snap +++ b/app/containers/List/__snapshots__/List.test.tsx.snap @@ -6150,7 +6150,7 @@ exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 24, }, [ @@ -6202,7 +6202,7 @@ exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` }, undefined, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -6257,7 +6257,7 @@ exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 24, }, [ @@ -6398,7 +6398,7 @@ exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 24, }, [ @@ -6450,7 +6450,7 @@ exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` }, undefined, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -6505,7 +6505,7 @@ exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 24, }, [ @@ -6721,7 +6721,7 @@ exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 24, }, [ @@ -6773,7 +6773,7 @@ exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` }, undefined, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -6828,7 +6828,7 @@ exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 24, }, [ @@ -6969,7 +6969,7 @@ exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 24, }, [ @@ -7021,7 +7021,7 @@ exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` }, undefined, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -7076,7 +7076,7 @@ exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 24, }, [ diff --git a/app/containers/MediaCallHeader/MediaCallHeader.test.tsx b/app/containers/MediaCallHeader/MediaCallHeader.test.tsx index f145f038d7e..d4229750e97 100644 --- a/app/containers/MediaCallHeader/MediaCallHeader.test.tsx +++ b/app/containers/MediaCallHeader/MediaCallHeader.test.tsx @@ -11,6 +11,8 @@ import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots'; // Mock alert global.alert = jest.fn(); +const mockCallStartTime = 1713340800000; + // Helper to create a mock call const createMockCall = (overrides: Record = {}) => ({ state: 'active', @@ -42,7 +44,7 @@ const setStoreState = (overrides: Partial {children}; +beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(mockCallStartTime); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + describe('MediaCallHeader', () => { beforeEach(() => { useCallStore.getState().reset(); diff --git a/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap b/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap index 77beb9effca..86f5b4ca9d3 100644 --- a/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap +++ b/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap @@ -190,6 +190,7 @@ exports[`Story Snapshots: ActiveCall should match snapshot 1`] = ` [ { "height": 16, + "textAlign": "center", "textAlignVertical": "center", "width": 16, }, @@ -228,7 +229,7 @@ exports[`Story Snapshots: ActiveCall should match snapshot 1`] = ` Bob Burnquist - - 16448:41:15 + 00:00 @@ -536,6 +537,7 @@ exports[`Story Snapshots: Collapsed should match snapshot 1`] = ` [ { "height": 16, + "textAlign": "center", "textAlignVertical": "center", "width": 16, }, @@ -574,7 +576,7 @@ exports[`Story Snapshots: Collapsed should match snapshot 1`] = ` Bob Burnquist - - 16448:41:15 + 00:00 @@ -882,6 +884,7 @@ exports[`Story Snapshots: ConnectingCall should match snapshot 1`] = ` [ { "height": 16, + "textAlign": "center", "textAlignVertical": "center", "width": 16, }, @@ -1224,6 +1227,7 @@ exports[`Story Snapshots: Focused should match snapshot 1`] = ` [ { "height": 16, + "textAlign": "center", "textAlignVertical": "center", "width": 16, }, @@ -1262,7 +1266,7 @@ exports[`Story Snapshots: Focused should match snapshot 1`] = ` Bob Burnquist - - 16448:41:15 + 00:00 @@ -1590,6 +1594,7 @@ exports[`Story Snapshots: WithRemoteHeld should match snapshot 1`] = ` [ { "height": 16, + "textAlign": "center", "textAlignVertical": "center", "width": 16, }, @@ -1628,7 +1633,7 @@ exports[`Story Snapshots: WithRemoteHeld should match snapshot 1`] = ` Bob Burnquist - - 16448:41:15 + 00:00 @@ -1936,6 +1941,7 @@ exports[`Story Snapshots: WithRemoteMuted should match snapshot 1`] = ` [ { "height": 16, + "textAlign": "center", "textAlignVertical": "center", "width": 16, }, @@ -1974,7 +1980,7 @@ exports[`Story Snapshots: WithRemoteMuted should match snapshot 1`] = ` Bob Burnquist - - 16448:41:15 + 00:00 diff --git a/app/containers/NewMediaCall/Container.stories.tsx b/app/containers/NewMediaCall/Container.stories.tsx new file mode 100644 index 00000000000..0d460e15853 --- /dev/null +++ b/app/containers/NewMediaCall/Container.stories.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import BottomSheet from '@discord/bottom-sheet'; + +import { Container } from './Container'; + +const styles = StyleSheet.create({ + root: { + flex: 1 + }, + placeholder: { + marginTop: 8, + padding: 12, + borderRadius: 6, + backgroundColor: '#efefef' + } +}); + +const PlaceholderChildren = () => ( + + Mock children placeholder + +); + +const BottomSheetWrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + +); + +export default { + title: 'NewMediaCall/Container', + component: Container, + decorators: [ + (Story: React.ComponentType) => ( + + + + ) + ] +}; + +export const Default = () => ( + + + +); diff --git a/app/containers/NewMediaCall/Container.tsx b/app/containers/NewMediaCall/Container.tsx new file mode 100644 index 00000000000..94724e34b2f --- /dev/null +++ b/app/containers/NewMediaCall/Container.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '../../theme'; + +export const Container = ({ children }: { children: React.ReactElement[] | React.ReactElement }) => { + const { colors } = useTheme(); + + return {children}; +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 16, + paddingTop: 16 + } +}); diff --git a/app/containers/NewMediaCall/CreateCall.stories.tsx b/app/containers/NewMediaCall/CreateCall.stories.tsx new file mode 100644 index 00000000000..690d3eeb825 --- /dev/null +++ b/app/containers/NewMediaCall/CreateCall.stories.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import * as ActionSheetModule from '../ActionSheet'; +import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstance'; +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; +import { CreateCall } from './CreateCall'; +import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; + +const mediaSession = mediaSessionInstance as unknown as { startCall: (...args: unknown[]) => void }; +mediaSession.startCall = () => {}; + +try { + Object.defineProperty(ActionSheetModule, 'hideActionSheetRef', { + value: () => {}, + writable: true + }); +} catch { + // noop: in environments where export is not re-definable, + // the original hideActionSheetRef is already safe with optional chaining. +} + +const setStoreState = (selectedPeer: ReturnType['selectedPeer']) => { + usePeerAutocompleteStore.setState({ selectedPeer }); +}; + +const userPeer: TPeerItem = { + type: 'user', + value: 'user-1', + label: 'Alice Johnson', + username: 'alice.johnson' +}; + +export default { + title: 'NewMediaCall/CreateCall', + component: CreateCall +}; + +export const Disabled = () => { + setStoreState(null); + return ; +}; + +export const Enabled = () => { + setStoreState(userPeer); + return ; +}; diff --git a/app/containers/NewMediaCall/CreateCall.test.tsx b/app/containers/NewMediaCall/CreateCall.test.tsx new file mode 100644 index 00000000000..e6514083daf --- /dev/null +++ b/app/containers/NewMediaCall/CreateCall.test.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { Provider } from 'react-redux'; + +import { CreateCall } from './CreateCall'; +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; +import { mockedStore } from '../../reducers/mockedStore'; +import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; +import * as stories from './CreateCall.stories'; +import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots'; + +const mockStartCall = jest.fn(); +const mockHideActionSheet = jest.fn(); + +jest.mock('../../lib/services/voip/MediaSessionInstance', () => { + const instance = {}; + Object.defineProperty(instance, 'startCall', { + value: (...args: unknown[]) => mockStartCall(...args), + writable: false, + configurable: false + }); + return { mediaSessionInstance: instance }; +}); + +jest.mock('../ActionSheet', () => ({ + ...jest.requireActual('../ActionSheet'), + hideActionSheetRef: () => mockHideActionSheet() +})); + +const setStoreState = (selectedPeer: TPeerItem | null) => { + usePeerAutocompleteStore.setState({ selectedPeer }); +}; + +const Wrapper = ({ children }: { children: React.ReactNode }) => {children}; + +const userPeer: TPeerItem = { + type: 'user', + value: 'user-1', + label: 'Alice Johnson', + username: 'alice.johnson' +}; + +const sipPeer: TPeerItem = { + type: 'sip', + value: '+5511999999999', + label: '+55 11 99999-9999' +}; + +describe('CreateCall', () => { + beforeEach(() => { + jest.clearAllMocks(); + usePeerAutocompleteStore.setState({ selectedPeer: null }); + }); + + it('should render the call button', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('new-media-call-button')).toBeTruthy(); + }); + + it('should be disabled when no peer is selected', () => { + setStoreState(null); + const { getByTestId } = render( + + + + ); + + const button = getByTestId('new-media-call-button'); + expect(button.props.accessibilityState?.disabled).toBe(true); + }); + + it('should not call startCall or hideActionSheet when disabled and pressed', () => { + setStoreState(null); + const { getByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('new-media-call-button')); + expect(mockStartCall).not.toHaveBeenCalled(); + expect(mockHideActionSheet).not.toHaveBeenCalled(); + }); + + it('should be enabled when user peer is selected', () => { + setStoreState(userPeer); + const { getByTestId } = render( + + + + ); + + const button = getByTestId('new-media-call-button'); + expect(button.props.accessibilityState?.disabled).toBe(false); + }); + + it('should call startCall with user type when user peer is selected and pressed', () => { + setStoreState(userPeer); + const { getByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('new-media-call-button')); + expect(mockStartCall).toHaveBeenCalledTimes(1); + expect(mockStartCall).toHaveBeenCalledWith('user-1', 'user'); + expect(mockHideActionSheet).toHaveBeenCalledTimes(1); + }); + + it('should call startCall when SIP peer is selected and pressed', () => { + setStoreState(sipPeer); + const { getByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('new-media-call-button')); + expect(mockStartCall).toHaveBeenCalledTimes(1); + expect(mockStartCall).toHaveBeenCalledWith('+5511999999999', 'sip'); + expect(mockHideActionSheet).toHaveBeenCalledTimes(1); + }); +}); + +generateSnapshots(stories); diff --git a/app/containers/NewMediaCall/CreateCall.tsx b/app/containers/NewMediaCall/CreateCall.tsx new file mode 100644 index 00000000000..3cbfa1e1916 --- /dev/null +++ b/app/containers/NewMediaCall/CreateCall.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Pressable, StyleSheet, Text } from 'react-native'; + +import I18n from '../../i18n'; +import { useTheme } from '../../theme'; +import { CustomIcon } from '../CustomIcon'; +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; +import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstance'; +import { hideActionSheetRef } from '../ActionSheet'; +import sharedStyles from '../../views/Styles'; + +export const CreateCall = () => { + const { colors } = useTheme(); + + const selectedPeer = usePeerAutocompleteStore(state => state.selectedPeer); + + const handleCall = () => { + if (!selectedPeer) { + return; + } + + mediaSessionInstance.startCall(selectedPeer.value, selectedPeer.type); + hideActionSheetRef(); + }; + + const isCallDisabled = !selectedPeer; + + return ( + + + + {I18n.t('Call')} + + + ); +}; + +const styles = StyleSheet.create({ + callButton: { + height: 52, + marginTop: 32, + borderRadius: 4, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: 4 + }, + callText: { + fontSize: 16, + lineHeight: 24, + ...sharedStyles.textMedium + } +}); diff --git a/app/containers/NewMediaCall/FilterHeader.stories.tsx b/app/containers/NewMediaCall/FilterHeader.stories.tsx new file mode 100644 index 00000000000..a375668bd4a --- /dev/null +++ b/app/containers/NewMediaCall/FilterHeader.stories.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import BottomSheet from '@discord/bottom-sheet'; + +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; +import { FilterHeader } from './FilterHeader'; + +const styles = StyleSheet.create({ + root: { + flex: 1 + } +}); + +const setStoreState = (filter: string) => { + usePeerAutocompleteStore.setState({ + filter, + selectedPeer: null, + options: [] + }); +}; + +const BottomSheetWrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + +); + +export default { + title: 'NewMediaCall/FilterHeader', + component: FilterHeader, + decorators: [ + (Story: React.ComponentType) => ( + + + + ) + ] +}; + +export const Default = () => { + setStoreState(''); + return ; +}; + +export const WithFilter = () => { + setStoreState('alice'); + return ; +}; diff --git a/app/containers/NewMediaCall/FilterHeader.test.tsx b/app/containers/NewMediaCall/FilterHeader.test.tsx new file mode 100644 index 00000000000..434a90a548d --- /dev/null +++ b/app/containers/NewMediaCall/FilterHeader.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react-native'; +import { Provider } from 'react-redux'; + +import I18n from '../../i18n'; +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; +import { mockedStore } from '../../reducers/mockedStore'; +import { textInputDebounceTime } from '../../lib/constants/debounceConfig'; +import { FilterHeader } from './FilterHeader'; +import * as stories from './FilterHeader.stories'; +import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots'; + +const Wrapper = ({ children }: { children: React.ReactNode }) => {children}; + +describe('FilterHeader', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + usePeerAutocompleteStore.getState().reset(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('should render title, label and search input', () => { + const { getByText, getByTestId } = render( + + + + ); + + expect(getByText(I18n.t('New_call'))).toBeTruthy(); + expect(getByText(I18n.t('Enter_username_or_number'))).toBeTruthy(); + expect(getByTestId('new-media-call-search-input')).toBeTruthy(); + }); + + it('should update filter, clear selection and fetch options after debounce', () => { + const setFilter = jest.fn(); + const clearSelection = jest.fn(); + const fetchOptions = jest.fn(); + usePeerAutocompleteStore.setState({ + filter: '', + selectedPeer: null, + options: [], + setFilter, + clearSelection, + fetchOptions + }); + + const { getByTestId } = render( + + + + ); + + fireEvent.changeText(getByTestId('new-media-call-search-input'), 'alice'); + + expect(setFilter).toHaveBeenCalledWith('alice'); + expect(clearSelection).toHaveBeenCalledTimes(1); + expect(fetchOptions).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(textInputDebounceTime); + }); + + expect(fetchOptions).toHaveBeenCalledWith('alice'); + }); +}); + +generateSnapshots(stories); diff --git a/app/containers/NewMediaCall/FilterHeader.tsx b/app/containers/NewMediaCall/FilterHeader.tsx new file mode 100644 index 00000000000..ab86283d6d3 --- /dev/null +++ b/app/containers/NewMediaCall/FilterHeader.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import I18n from '../../i18n'; +import { useTheme } from '../../theme'; +import { FormTextInput } from '../TextInput'; +import sharedStyles from '../../views/Styles'; +import { textInputDebounceTime } from '../../lib/constants/debounceConfig'; +import { useDebounce } from '../../lib/methods/helpers'; +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; + +export const FilterHeader = (): React.ReactElement => { + const { colors } = useTheme(); + + const filter = usePeerAutocompleteStore(state => state.filter); + const setFilter = usePeerAutocompleteStore(state => state.setFilter); + const fetchOptions = usePeerAutocompleteStore(state => state.fetchOptions); + const clearSelection = usePeerAutocompleteStore(state => state.clearSelection); + + const debouncedFetchOptions = useDebounce((value: string) => { + fetchOptions(value); + }, textInputDebounceTime); + + const handleChangeText = (value: string) => { + setFilter(value); + clearSelection(); + debouncedFetchOptions(value); + }; + + return ( + <> + {I18n.t('New_call')} + + + + + {I18n.t('Enter_username_or_number')} + + ); +}; + +const styles = StyleSheet.create({ + title: { + fontSize: 20, + lineHeight: 28, + marginTop: 4, + ...sharedStyles.textBold + }, + searchContainer: { + marginTop: 28, + marginBottom: 8 + }, + searchInputContainer: { + marginBottom: 0 + }, + inputLabel: { + fontSize: 16, + lineHeight: 24, + marginBottom: 16, + ...sharedStyles.textRegular + } +}); diff --git a/app/containers/NewMediaCall/NewMediaCall.stories.tsx b/app/containers/NewMediaCall/NewMediaCall.stories.tsx new file mode 100644 index 00000000000..89daf4e4d39 --- /dev/null +++ b/app/containers/NewMediaCall/NewMediaCall.stories.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import BottomSheet from '@discord/bottom-sheet'; + +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; +import { NewMediaCall } from './NewMediaCall'; + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#ccc' + }, + container: { + flex: 1, + padding: 16 + } +}); + +const mockUserOptions = [ + { + type: 'user' as const, + value: 'user-1', + label: 'Alice Johnson', + username: 'alice.johnson' + }, + { + type: 'user' as const, + value: 'user-2', + label: 'Bob Smith', + username: 'bob.smith' + } +]; + +const setStoreByVariant = (variant: 'empty' | 'searching' | 'userSelected' | 'sipSelected') => { + if (variant === 'empty') { + usePeerAutocompleteStore.setState({ + filter: '', + options: [], + selectedPeer: null + }); + return; + } + + if (variant === 'searching') { + usePeerAutocompleteStore.setState({ + filter: 'al', + options: mockUserOptions, + selectedPeer: null + }); + return; + } + + if (variant === 'userSelected') { + usePeerAutocompleteStore.setState({ + filter: '', + options: [], + selectedPeer: { + type: 'user', + value: 'alice.johnson', + label: 'Alice Johnson', + username: 'alice.johnson' + } + }); + return; + } + + usePeerAutocompleteStore.setState({ + filter: '', + options: [], + selectedPeer: { type: 'sip', value: '+5511999999999', label: '+55 11 99999-9999' } + }); +}; + +const BottomSheetWrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + +); + +export default { + title: 'NewMediaCall/NewMediaCall', + component: NewMediaCall, + decorators: [ + (Story: React.ComponentType) => ( + + + + ) + ] +}; + +export const Empty = () => { + setStoreByVariant('empty'); + return ( + + + + ); +}; + +export const Searching = () => { + setStoreByVariant('searching'); + return ( + + + + ); +}; + +export const UserSelected = () => { + setStoreByVariant('userSelected'); + return ( + + + + ); +}; + +export const SipSelected = () => { + setStoreByVariant('sipSelected'); + return ( + + + + ); +}; diff --git a/app/containers/NewMediaCall/NewMediaCall.tsx b/app/containers/NewMediaCall/NewMediaCall.tsx new file mode 100644 index 00000000000..d4da5e70db7 --- /dev/null +++ b/app/containers/NewMediaCall/NewMediaCall.tsx @@ -0,0 +1,23 @@ +import React, { useEffect } from 'react'; + +import { PeerList } from './PeerList'; +import { SelectedPeer } from './SelectedPeer'; +import { Container } from './Container'; +import { CreateCall } from './CreateCall'; +import { FilterHeader } from './FilterHeader'; +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; + +export const NewMediaCall = (): React.ReactElement => { + // reset the store when the action sheet is closed + const reset = usePeerAutocompleteStore(state => state.reset); + useEffect(() => () => reset(), [reset]); + + return ( + + + + + + + ); +}; diff --git a/app/containers/NewMediaCall/PeerItem.stories.tsx b/app/containers/NewMediaCall/PeerItem.stories.tsx new file mode 100644 index 00000000000..7193a1aa78f --- /dev/null +++ b/app/containers/NewMediaCall/PeerItem.stories.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; +import { PeerItem } from './PeerItem'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 16, + gap: 8 + }, + label: { + fontSize: 13, + opacity: 0.6 + } +}); + +const onSelectOption = () => {}; + +const userItem: TPeerItem = { + type: 'user', + value: 'user-1', + label: 'Alice Johnson', + username: 'alice.johnson' +}; + +const sipItem: TPeerItem = { + type: 'sip', + value: '+5511999999999', + label: '+55 11 99999-9999' +}; + +const longLabelItem: TPeerItem = { + type: 'user', + value: 'user-2', + label: 'Long display name to validate text truncation behavior for PeerItem in narrow widths', + username: 'very.long.username' +}; + +export default { + title: 'NewMediaCall/PeerItem', + component: PeerItem +}; + +export const All = () => ( + + User + + + SIP + + + Long label + + +); diff --git a/app/containers/NewMediaCall/PeerItem.test.tsx b/app/containers/NewMediaCall/PeerItem.test.tsx new file mode 100644 index 00000000000..560fb8e1d6f --- /dev/null +++ b/app/containers/NewMediaCall/PeerItem.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { Provider } from 'react-redux'; + +import { PeerItem } from './PeerItem'; +import { mockedStore } from '../../reducers/mockedStore'; +import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; +import * as stories from './PeerItem.stories'; +import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots'; + +const onSelectOptionMock = jest.fn(); + +const Wrapper = ({ children }: { children: React.ReactNode }) => {children}; + +const userItem: TPeerItem = { + type: 'user', + value: 'user-1', + label: 'Alice Johnson', + username: 'alice.johnson' +}; + +const sipItem: TPeerItem = { + type: 'sip', + value: '+5511999999999', + label: '+55 11 99999-9999' +}; + +describe('PeerItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render user option with correct testID', () => { + const { getByTestId, getByText } = render( + + + + ); + + expect(getByTestId('new-media-call-option-user-1')).toBeTruthy(); + expect(getByText('Alice Johnson')).toBeTruthy(); + }); + + it('should render SIP option with correct testID', () => { + const { getByTestId, getByText } = render( + + + + ); + + expect(getByTestId('new-media-call-option-+5511999999999')).toBeTruthy(); + expect(getByText('+55 11 99999-9999')).toBeTruthy(); + }); + + it('should call onSelectOption with item when pressed', () => { + const { getByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('new-media-call-option-user-1')); + expect(onSelectOptionMock).toHaveBeenCalledTimes(1); + expect(onSelectOptionMock).toHaveBeenCalledWith(userItem); + }); + + it('should call onSelectOption with SIP item when pressed', () => { + const { getByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('new-media-call-option-+5511999999999')); + expect(onSelectOptionMock).toHaveBeenCalledTimes(1); + expect(onSelectOptionMock).toHaveBeenCalledWith(sipItem); + }); +}); + +generateSnapshots(stories); diff --git a/app/containers/NewMediaCall/PeerItem.tsx b/app/containers/NewMediaCall/PeerItem.tsx new file mode 100644 index 00000000000..d316c86a3c4 --- /dev/null +++ b/app/containers/NewMediaCall/PeerItem.tsx @@ -0,0 +1,29 @@ +import { Pressable, StyleSheet } from 'react-native'; + +import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; +import { PeerItemInner } from './PeerItemInner'; +import { useTheme } from '../../theme'; +import { isIOS } from '../../lib/methods/helpers'; + +export const PeerItem = ({ item, onSelectOption }: { item: TPeerItem; onSelectOption: (item: TPeerItem) => void }) => { + const { colors } = useTheme(); + return ( + [ + styles.container, + { backgroundColor: pressed && isIOS ? colors.surfaceSelected : colors.surfaceLight } + ]} + onPress={() => onSelectOption(item)} + testID={`new-media-call-option-${item.value}`} + android_ripple={{ color: colors.surfaceSelected }}> + + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingVertical: 10, + paddingHorizontal: 12 + } +}); diff --git a/app/containers/NewMediaCall/PeerItemInner.tsx b/app/containers/NewMediaCall/PeerItemInner.tsx new file mode 100644 index 00000000000..ac5c86e48e7 --- /dev/null +++ b/app/containers/NewMediaCall/PeerItemInner.tsx @@ -0,0 +1,57 @@ +import { View, Text, StyleSheet } from 'react-native'; + +import { useTheme } from '../../theme'; +import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; +import { CustomIcon } from '../CustomIcon'; +import Avatar from '../Avatar'; +import Status from '../Status'; +import sharedStyles from '../../views/Styles'; + +export const PeerItemInner = ({ item }: { item: TPeerItem }) => { + const { colors } = useTheme(); + + const isSip = item.type === 'sip'; + + return ( + + + {isSip ? ( + + ) : ( + + )} + + + {!isSip ? : null} + + {item.label} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexShrink: 1, + flexDirection: 'row', + alignItems: 'center', + gap: 4 + }, + icon: { + width: 28, + alignItems: 'center' + }, + nameContainer: { + flexShrink: 1, + flexDirection: 'row', + alignItems: 'center', + gap: 4 + }, + name: { + flexShrink: 1, + fontSize: 18, + lineHeight: 26, + ...sharedStyles.textMedium + } +}); diff --git a/app/containers/NewMediaCall/PeerList.stories.tsx b/app/containers/NewMediaCall/PeerList.stories.tsx new file mode 100644 index 00000000000..16e253e21e4 --- /dev/null +++ b/app/containers/NewMediaCall/PeerList.stories.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; +import { PeerList } from './PeerList'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 16 + } +}); + +const mixedOptions: TPeerItem[] = [ + { + type: 'sip', + value: '+5511988887777', + label: '+55 11 98888-7777' + }, + { + type: 'user', + value: 'user-1', + label: 'Alice Johnson', + username: 'alice.johnson' + }, + { + type: 'user', + value: 'user-2', + label: 'Long display name to validate text truncation behavior for PeerItem in narrow widths', + username: 'very.long.username' + }, + { + type: 'user', + value: 'user-3', + label: 'Bob Smith', + username: 'bob.smith' + } +]; + +const setStoreState = (options: TPeerItem[]) => { + usePeerAutocompleteStore.setState({ + options, + selectedPeer: null + }); +}; + +export default { + title: 'NewMediaCall/PeerList', + component: PeerList +}; + +export const All = () => { + setStoreState(mixedOptions); + return ( + + + + ); +}; diff --git a/app/containers/NewMediaCall/PeerList.test.tsx b/app/containers/NewMediaCall/PeerList.test.tsx new file mode 100644 index 00000000000..76ea517fe82 --- /dev/null +++ b/app/containers/NewMediaCall/PeerList.test.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { Provider } from 'react-redux'; + +import { PeerList } from './PeerList'; +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; +import { mockedStore } from '../../reducers/mockedStore'; +import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; +import * as stories from './PeerList.stories'; +import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots'; + +const setStoreState = (options: TPeerItem[], selectedPeer: TPeerItem | null = null) => { + usePeerAutocompleteStore.setState({ options, selectedPeer }); +}; + +const Wrapper = ({ children }: { children: React.ReactNode }) => {children}; + +const mockOptions: TPeerItem[] = [ + { + type: 'sip', + value: '+5511988887777', + label: '+55 11 98888-7777' + }, + { + type: 'user', + value: 'user-1', + label: 'Alice Johnson', + username: 'alice.johnson' + }, + { + type: 'user', + value: 'user-2', + label: 'Bob Smith', + username: 'bob.smith' + } +]; + +describe('PeerList', () => { + beforeEach(() => { + jest.clearAllMocks(); + usePeerAutocompleteStore.setState({ options: [], selectedPeer: null }); + }); + + it('should render options when no peer selected', () => { + setStoreState(mockOptions); + const { getByTestId, getByText } = render( + + + + ); + + expect(getByTestId('new-media-call-option-+5511988887777')).toBeTruthy(); + expect(getByTestId('new-media-call-option-user-1')).toBeTruthy(); + expect(getByTestId('new-media-call-option-user-2')).toBeTruthy(); + expect(getByText('Alice Johnson')).toBeTruthy(); + expect(getByText('Bob Smith')).toBeTruthy(); + }); + + it('should return null when peer is selected', () => { + setStoreState(mockOptions, mockOptions[1]); + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('new-media-call-option-user-1')).toBeNull(); + }); + + it('should set selected peer, clear filter and fetch options when option is pressed', () => { + usePeerAutocompleteStore.setState({ + options: mockOptions, + selectedPeer: null, + filter: 'Alice' + }); + + const { getByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('new-media-call-option-user-1')); + + expect(usePeerAutocompleteStore.getState().selectedPeer).toEqual( + expect.objectContaining({ + type: 'user', + value: 'user-1', + label: 'Alice Johnson', + username: 'alice.johnson' + }) + ); + expect(usePeerAutocompleteStore.getState().filter).toBe(''); + expect(usePeerAutocompleteStore.getState().options).toEqual([]); + }); + + it('should pass sip item correctly when SIP option is pressed', () => { + usePeerAutocompleteStore.setState({ + options: mockOptions, + selectedPeer: null, + filter: '+55 11 98888-7777' + }); + + const { getByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('new-media-call-option-+5511988887777')); + + expect(usePeerAutocompleteStore.getState().selectedPeer).toEqual( + expect.objectContaining({ + type: 'sip', + value: '+5511988887777', + label: '+55 11 98888-7777' + }) + ); + expect(usePeerAutocompleteStore.getState().filter).toBe(''); + expect(usePeerAutocompleteStore.getState().options).toEqual([]); + }); +}); + +generateSnapshots(stories); diff --git a/app/containers/NewMediaCall/PeerList.tsx b/app/containers/NewMediaCall/PeerList.tsx new file mode 100644 index 00000000000..68470b8903f --- /dev/null +++ b/app/containers/NewMediaCall/PeerList.tsx @@ -0,0 +1,42 @@ +import { FlatList } from 'react-native'; + +import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; +import { PeerItem } from './PeerItem'; +import * as List from '../List'; +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; + +export const PeerList = () => { + const setSelectedPeer = usePeerAutocompleteStore(state => state.setSelectedPeer); + const selectedPeer = usePeerAutocompleteStore(state => state.selectedPeer); + const options = usePeerAutocompleteStore(state => state.options); + + if (selectedPeer) return null; + + const handleSelectOption = (option: TPeerItem) => { + const peerItem: TPeerItem = + option.type === 'sip' + ? { type: 'sip', value: option.value, label: option.label } + : { + type: 'user', + value: option.value, + label: option.label, + username: option.username, + callerId: option.callerId + }; + + setSelectedPeer(peerItem); + }; + + const renderItem = ({ item }: { item: TPeerItem }) => ; + + return ( + item.value} + keyboardShouldPersistTaps='always' + renderItem={renderItem} + ItemSeparatorComponent={List.Separator} + /> + ); +}; diff --git a/app/containers/NewMediaCall/SelectedPeer.stories.tsx b/app/containers/NewMediaCall/SelectedPeer.stories.tsx new file mode 100644 index 00000000000..db095b3cc15 --- /dev/null +++ b/app/containers/NewMediaCall/SelectedPeer.stories.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; +import { SelectedPeerInner } from './SelectedPeer'; +import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 16, + gap: 12 + }, + sectionLabel: { + fontSize: 13, + opacity: 0.6 + } +}); + +const setStoreState = (selectedPeer: ReturnType['selectedPeer']) => { + usePeerAutocompleteStore.setState({ + selectedPeer + }); +}; + +const userPeer: TPeerItem = { + type: 'user', + value: 'user-1', + label: 'Alice Johnson', + username: 'alice.johnson' +}; + +const sipPeer: TPeerItem = { + type: 'sip', + value: '+5511999999999', + label: '+55 11 99999-9999' +}; + +const longUsernamePeer: TPeerItem = { + type: 'user', + value: 'user-2', + label: 'Long display name to validate text truncation behavior for SelectedPeer in narrow widths', + username: 'this.is.a.very.long.username.for.storybook.preview' +}; + +export default { + title: 'NewMediaCall/SelectedPeer', + component: SelectedPeerInner +}; + +export const All = () => ( + + User variant + + SIP variant + + Long username variant + + +); + +export const User = () => { + setStoreState(userPeer); + return ( + + User variant + + + ); +}; + +export const Sip = () => { + setStoreState(sipPeer); + return ( + + SIP variant + + + ); +}; + +export const LongUsername = () => { + setStoreState(longUsernamePeer); + return ( + + Long username variant + + + ); +}; diff --git a/app/containers/NewMediaCall/SelectedPeer.test.tsx b/app/containers/NewMediaCall/SelectedPeer.test.tsx new file mode 100644 index 00000000000..2b58fffae8c --- /dev/null +++ b/app/containers/NewMediaCall/SelectedPeer.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { Provider } from 'react-redux'; + +import { SelectedPeer } from './SelectedPeer'; +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; +import { mockedStore } from '../../reducers/mockedStore'; +import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; +import * as stories from './SelectedPeer.stories'; +import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots'; + +const setStoreState = (selectedPeer: TPeerItem | null) => { + usePeerAutocompleteStore.setState({ selectedPeer }); +}; + +const Wrapper = ({ children }: { children: React.ReactNode }) => {children}; + +const userPeer: TPeerItem = { + type: 'user', + value: 'user-1', + label: 'Alice Johnson', + username: 'alice.johnson' +}; + +const sipPeer: TPeerItem = { + type: 'sip', + value: '+5511999999999', + label: '+55 11 99999-9999' +}; + +describe('SelectedPeer', () => { + beforeEach(() => { + jest.clearAllMocks(); + usePeerAutocompleteStore.setState({ selectedPeer: null }); + }); + + it('should return null when no peer is selected', () => { + setStoreState(null); + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('new-media-call-clear-selected-peer')).toBeNull(); + }); + + it('should render selected user peer with label', () => { + setStoreState(userPeer); + const { getByTestId, getByText } = render( + + + + ); + + expect(getByTestId('new-media-call-clear-selected-peer')).toBeTruthy(); + expect(getByText('Alice Johnson')).toBeTruthy(); + }); + + it('should render selected SIP peer with label', () => { + setStoreState(sipPeer); + const { getByTestId, getByText } = render( + + + + ); + + expect(getByTestId('new-media-call-clear-selected-peer')).toBeTruthy(); + expect(getByText('+55 11 99999-9999')).toBeTruthy(); + }); + + it('should call clearSelection when clear button is pressed', () => { + const clearSelection = jest.fn(); + usePeerAutocompleteStore.setState({ selectedPeer: userPeer, clearSelection }); + + const { getByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('new-media-call-clear-selected-peer')); + expect(clearSelection).toHaveBeenCalledTimes(1); + }); +}); + +generateSnapshots(stories); diff --git a/app/containers/NewMediaCall/SelectedPeer.tsx b/app/containers/NewMediaCall/SelectedPeer.tsx new file mode 100644 index 00000000000..e3faee6caa9 --- /dev/null +++ b/app/containers/NewMediaCall/SelectedPeer.tsx @@ -0,0 +1,59 @@ +import { View, StyleSheet } from 'react-native'; +import { BorderlessButton } from 'react-native-gesture-handler'; + +import { useTheme } from '../../theme'; +import { CustomIcon } from '../CustomIcon'; +import I18n from '../../i18n'; +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; +import { PeerItemInner } from './PeerItemInner'; +import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; + +export const SelectedPeer = () => { + const selectedPeer = usePeerAutocompleteStore(state => state.selectedPeer); + return ; +}; + +export const SelectedPeerInner = ({ selectedPeer }: { selectedPeer: TPeerItem | null }) => { + const { colors } = useTheme(); + const clearSelection = usePeerAutocompleteStore(state => state.clearSelection); + + if (!selectedPeer) return null; + + return ( + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center' + }, + selectedTag: { + flexShrink: 1, + minHeight: 48, + borderRadius: 4, + paddingHorizontal: 10, + flexDirection: 'row', + alignItems: 'center', + gap: 10 + }, + removeButton: { + padding: 2 + } +}); diff --git a/app/containers/NewMediaCall/__snapshots__/CreateCall.test.tsx.snap b/app/containers/NewMediaCall/__snapshots__/CreateCall.test.tsx.snap new file mode 100644 index 00000000000..75b24385227 --- /dev/null +++ b/app/containers/NewMediaCall/__snapshots__/CreateCall.test.tsx.snap @@ -0,0 +1,199 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Story Snapshots: Disabled should match snapshot 1`] = ` + + +  + + + Call + + +`; + +exports[`Story Snapshots: Enabled should match snapshot 1`] = ` + + +  + + + Call + + +`; diff --git a/app/containers/NewMediaCall/__snapshots__/FilterHeader.test.tsx.snap b/app/containers/NewMediaCall/__snapshots__/FilterHeader.test.tsx.snap new file mode 100644 index 00000000000..6ab5db2b71b --- /dev/null +++ b/app/containers/NewMediaCall/__snapshots__/FilterHeader.test.tsx.snap @@ -0,0 +1,383 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Story Snapshots: Default should match snapshot 1`] = ` + + + + + New call + + + + + + + + +  + + + + + + + + Enter username or number + + + + +`; + +exports[`Story Snapshots: WithFilter should match snapshot 1`] = ` + + + + + New call + + + + + + + + +  + + + + + + + + Enter username or number + + + + +`; diff --git a/app/containers/NewMediaCall/__snapshots__/PeerItem.test.tsx.snap b/app/containers/NewMediaCall/__snapshots__/PeerItem.test.tsx.snap new file mode 100644 index 00000000000..627400c6caf --- /dev/null +++ b/app/containers/NewMediaCall/__snapshots__/PeerItem.test.tsx.snap @@ -0,0 +1,534 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Story Snapshots: All should match snapshot 1`] = ` + + + User + + + + + + + + + + +  + + + Alice Johnson + + + + + + SIP + + + + + +  + + + + + +55 11 99999-9999 + + + + + + Long label + + + + + + + + + + +  + + + Long display name to validate text truncation behavior for PeerItem in narrow widths + + + + + +`; diff --git a/app/containers/NewMediaCall/__snapshots__/PeerList.test.tsx.snap b/app/containers/NewMediaCall/__snapshots__/PeerList.test.tsx.snap new file mode 100644 index 00000000000..1bb3e59ba2f --- /dev/null +++ b/app/containers/NewMediaCall/__snapshots__/PeerList.test.tsx.snap @@ -0,0 +1,810 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Story Snapshots: All should match snapshot 1`] = ` + + + + + + + + +  + + + + + +55 11 98888-7777 + + + + + + + + + + + + + + + + +  + + + Alice Johnson + + + + + + + + + + + + + + + + +  + + + Long display name to validate text truncation behavior for PeerItem in narrow widths + + + + + + + + + + + + + + + + +  + + + Bob Smith + + + + + + + + +`; diff --git a/app/containers/NewMediaCall/__snapshots__/SelectedPeer.test.tsx.snap b/app/containers/NewMediaCall/__snapshots__/SelectedPeer.test.tsx.snap new file mode 100644 index 00000000000..bc87d2db65c --- /dev/null +++ b/app/containers/NewMediaCall/__snapshots__/SelectedPeer.test.tsx.snap @@ -0,0 +1,1411 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Story Snapshots: All should match snapshot 1`] = ` + + + User variant + + + + + + + + + + + +  + + + Alice Johnson + + + + + +  + + + + + + SIP variant + + + + + + +  + + + + + +55 11 99999-9999 + + + + + +  + + + + + + Long username variant + + + + + + + + + + + +  + + + Long display name to validate text truncation behavior for SelectedPeer in narrow widths + + + + + +  + + + + + +`; + +exports[`Story Snapshots: LongUsername should match snapshot 1`] = ` + + + Long username variant + + + + + + + + + + + +  + + + Long display name to validate text truncation behavior for SelectedPeer in narrow widths + + + + + +  + + + + + +`; + +exports[`Story Snapshots: Sip should match snapshot 1`] = ` + + + SIP variant + + + + + + +  + + + + + +55 11 99999-9999 + + + + + +  + + + + + +`; + +exports[`Story Snapshots: User should match snapshot 1`] = ` + + + User variant + + + + + + + + + + + +  + + + Alice Johnson + + + + + +  + + + + + +`; diff --git a/app/containers/NewMediaCall/index.tsx b/app/containers/NewMediaCall/index.tsx new file mode 100644 index 00000000000..1c1029bbf48 --- /dev/null +++ b/app/containers/NewMediaCall/index.tsx @@ -0,0 +1 @@ +export * from './NewMediaCall'; diff --git a/app/containers/RoomHeader/__snapshots__/RoomHeader.test.tsx.snap b/app/containers/RoomHeader/__snapshots__/RoomHeader.test.tsx.snap index 089fa4625c8..efcdbeefd5d 100644 --- a/app/containers/RoomHeader/__snapshots__/RoomHeader.test.tsx.snap +++ b/app/containers/RoomHeader/__snapshots__/RoomHeader.test.tsx.snap @@ -2142,7 +2142,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` "textAlign": "left", }, { - "color": "#E4E7EA", + "color": "#C1C7D0", "lineHeight": undefined, }, { diff --git a/app/containers/RoomItem/__snapshots__/RoomItem.test.tsx.snap b/app/containers/RoomItem/__snapshots__/RoomItem.test.tsx.snap index 971e7b8dc59..3cc914b4690 100644 --- a/app/containers/RoomItem/__snapshots__/RoomItem.test.tsx.snap +++ b/app/containers/RoomItem/__snapshots__/RoomItem.test.tsx.snap @@ -30023,6 +30023,7 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` [ { "height": 22, + "textAlign": "center", "textAlignVertical": "center", "width": 22, }, @@ -35642,6 +35643,7 @@ exports[`Story Snapshots: User should match snapshot 1`] = ` [ { "height": 22, + "textAlign": "center", "textAlignVertical": "center", "width": 22, }, @@ -36272,6 +36274,7 @@ exports[`Story Snapshots: User should match snapshot 1`] = ` [ { "height": 22, + "textAlign": "center", "textAlignVertical": "center", "width": 22, }, diff --git a/app/containers/RoomTypeIcon/__snapshots__/RoomTypeIcon.test.tsx.snap b/app/containers/RoomTypeIcon/__snapshots__/RoomTypeIcon.test.tsx.snap index d3f054edc7a..2852f4ea73c 100644 --- a/app/containers/RoomTypeIcon/__snapshots__/RoomTypeIcon.test.tsx.snap +++ b/app/containers/RoomTypeIcon/__snapshots__/RoomTypeIcon.test.tsx.snap @@ -18,6 +18,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "height": 30, + "textAlign": "center", "textAlignVertical": "center", "width": 30, }, @@ -56,6 +57,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "height": 30, + "textAlign": "center", "textAlignVertical": "center", "width": 30, }, diff --git a/app/containers/Status/Status.tsx b/app/containers/Status/Status.tsx index cb85da5e68f..bd479e6e84c 100644 --- a/app/containers/Status/Status.tsx +++ b/app/containers/Status/Status.tsx @@ -20,7 +20,8 @@ const Status = React.memo(({ style, status = 'offline', size = 32, ...props }: I { width: size * fontScale, height: size * fontScale, - textAlignVertical: 'center' + textAlignVertical: 'center', + textAlign: 'center' }, style ]; diff --git a/app/containers/Status/__snapshots__/Status.test.tsx.snap b/app/containers/Status/__snapshots__/Status.test.tsx.snap index e49ab566e73..85198cb740f 100644 --- a/app/containers/Status/__snapshots__/Status.test.tsx.snap +++ b/app/containers/Status/__snapshots__/Status.test.tsx.snap @@ -18,6 +18,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "height": 32, + "textAlign": "center", "textAlignVertical": "center", "width": 32, }, @@ -51,6 +52,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "height": 32, + "textAlign": "center", "textAlignVertical": "center", "width": 32, }, @@ -84,6 +86,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "height": 32, + "textAlign": "center", "textAlignVertical": "center", "width": 32, }, @@ -117,6 +120,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "height": 32, + "textAlign": "center", "textAlignVertical": "center", "width": 32, }, @@ -150,6 +154,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "height": 32, + "textAlign": "center", "textAlignVertical": "center", "width": 32, }, @@ -183,6 +188,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "height": 32, + "textAlign": "center", "textAlignVertical": "center", "width": 32, }, @@ -216,6 +222,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "height": 32, + "textAlign": "center", "textAlignVertical": "center", "width": 32, }, @@ -249,6 +256,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "height": 60, + "textAlign": "center", "textAlignVertical": "center", "width": 60, }, diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 34fdb91f4e3..7ca335bb0e4 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -329,6 +329,7 @@ "Enter_E2EE_Password_description": "Enter your E2EE password to view and send encrypted messages.\n\nPassword need to be entered on each device.", "Enter_manually": "Enter manually", "Enter_the_code": "Enter the code we just emailed you.", + "Enter_username_or_number": "Enter username or number", "Error_Download_file": "Error while downloading file", "Error_incorrect_password": "Incorrect password", "Error_play_video": "There was an error while playing this video", @@ -573,6 +574,7 @@ "Name_required": "Name required", "Never": "Never", "New_chat_transfer": "New chat transfer: {{agent}} returned the chat to the queue", + "New_call": "New call", "New_Message": "New message", "New_Password": "New password", "Next": "Next", diff --git a/app/lib/constants/colors.ts b/app/lib/constants/colors.ts index 2db1dbc6807..b36fe19de57 100644 --- a/app/lib/constants/colors.ts +++ b/app/lib/constants/colors.ts @@ -120,7 +120,7 @@ const dark = { fontAnnotation: '#9EA2A8', fontHint: '#9EA2A8', fontSecondaryInfo: '#9EA2A8', - fontDefault: '#E4E7EA', + fontDefault: '#C1C7D0', fontTitlesLabels: '#F2F3F5', fontInfo: '#739EDE', fontDanger: '#CF6E7A', diff --git a/app/lib/constants/defaultSettings.ts b/app/lib/constants/defaultSettings.ts index be63b56c559..d137e23905b 100644 --- a/app/lib/constants/defaultSettings.ts +++ b/app/lib/constants/defaultSettings.ts @@ -312,5 +312,8 @@ export const defaultSettings = { VoIP_TeamCollab_Ice_Gathering_Timeout: { type: 'valueAsNumber' }, + VoIP_TeamCollab_SIP_Integration_For_Internal_Calls: { + type: 'valueAsBoolean' + }, ...deprecatedSettings } as const; diff --git a/app/lib/hooks/useMediaCallPermission/useMediaCallPermission.test.ts b/app/lib/hooks/useMediaCallPermission/useMediaCallPermission.test.ts index 1999711152a..bce46a27bdd 100644 --- a/app/lib/hooks/useMediaCallPermission/useMediaCallPermission.test.ts +++ b/app/lib/hooks/useMediaCallPermission/useMediaCallPermission.test.ts @@ -25,10 +25,7 @@ describe('useMediaCallPermission', () => { const { result } = renderHook(() => useMediaCallPermission()); expect(result.current).toBe(false); - expect(mockUsePermissions).toHaveBeenCalledWith([ - 'allow-internal-voice-calls', - 'allow-external-voice-calls' - ]); + expect(mockUsePermissions).toHaveBeenCalledWith(['allow-internal-voice-calls', 'allow-external-voice-calls']); }); it('should return false when voip module exists but no voice call permissions', () => { diff --git a/app/lib/hooks/useNewMediaCall/index.ts b/app/lib/hooks/useNewMediaCall/index.ts new file mode 100644 index 00000000000..c34af4de557 --- /dev/null +++ b/app/lib/hooks/useNewMediaCall/index.ts @@ -0,0 +1 @@ +export * from './useNewMediaCall'; diff --git a/app/lib/hooks/useNewMediaCall/useNewMediaCall.test.tsx b/app/lib/hooks/useNewMediaCall/useNewMediaCall.test.tsx new file mode 100644 index 00000000000..aa49510bd8a --- /dev/null +++ b/app/lib/hooks/useNewMediaCall/useNewMediaCall.test.tsx @@ -0,0 +1,106 @@ +import { act, renderHook } from '@testing-library/react-native'; +import React from 'react'; + +import { NewMediaCall } from '../../../containers/NewMediaCall'; +import { useNewMediaCall } from './useNewMediaCall'; + +const mockUseSubscription = jest.fn(); +const mockUseMediaCallPermission = jest.fn(); +const mockShowActionSheetRef = jest.fn(); +const mockGetUidDirectMessage = jest.fn(); +const mockSetSelectedPeer = jest.fn(); +const mockGetState = jest.fn(() => ({ + setSelectedPeer: mockSetSelectedPeer +})); + +jest.mock('../useSubscription', () => ({ + useSubscription: (rid?: string) => mockUseSubscription(rid) +})); + +jest.mock('../useMediaCallPermission', () => ({ + useMediaCallPermission: () => mockUseMediaCallPermission() +})); + +jest.mock('../../../containers/ActionSheet', () => ({ + showActionSheetRef: (params: unknown) => mockShowActionSheetRef(params) +})); + +jest.mock('../../methods/helpers/helpers', () => ({ + getUidDirectMessage: (room: unknown) => mockGetUidDirectMessage(room) +})); + +jest.mock('../../services/voip/usePeerAutocompleteStore', () => ({ + usePeerAutocompleteStore: { + getState: () => mockGetState() + } +})); + +jest.mock('../../../containers/NewMediaCall', () => ({ + NewMediaCall: jest.fn(() => null) +})); + +describe('useNewMediaCall', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const expectNewMediaCallActionSheet = () => { + expect(mockShowActionSheetRef).toHaveBeenCalledTimes(1); + const [actionSheetArgs] = mockShowActionSheetRef.mock.calls[0]; + expect(React.isValidElement(actionSheetArgs.children)).toBe(true); + expect(actionSheetArgs.children.type).toBe(NewMediaCall); + }; + + it('should set selected peer and open action sheet when room has a direct message peer', () => { + const room = { name: 'Alice' }; + mockUseSubscription.mockReturnValue(room); + mockUseMediaCallPermission.mockReturnValue(true); + mockGetUidDirectMessage.mockReturnValue('user-id'); + + const { result } = renderHook(() => useNewMediaCall('room-id')); + + act(() => { + result.current.openNewMediaCall(); + }); + + expect(mockGetUidDirectMessage).toHaveBeenCalledWith(room); + expect(mockSetSelectedPeer).toHaveBeenCalledWith({ + type: 'user', + value: 'user-id', + label: 'Alice' + }); + expectNewMediaCallActionSheet(); + }); + + it('should open action sheet without setting selected peer when room has no direct message peer', () => { + const room = { name: 'Alice' }; + mockUseSubscription.mockReturnValue(room); + mockUseMediaCallPermission.mockReturnValue(true); + mockGetUidDirectMessage.mockReturnValue(undefined); + + const { result } = renderHook(() => useNewMediaCall('room-id')); + + act(() => { + result.current.openNewMediaCall(); + }); + + expect(mockGetUidDirectMessage).toHaveBeenCalledWith(room); + expect(mockSetSelectedPeer).not.toHaveBeenCalled(); + expectNewMediaCallActionSheet(); + }); + + it('should open action sheet without resolving direct message peer when room is missing', () => { + mockUseSubscription.mockReturnValue(undefined); + mockUseMediaCallPermission.mockReturnValue(false); + + const { result } = renderHook(() => useNewMediaCall('room-id')); + + act(() => { + result.current.openNewMediaCall(); + }); + + expect(mockGetUidDirectMessage).not.toHaveBeenCalled(); + expect(mockSetSelectedPeer).not.toHaveBeenCalled(); + expectNewMediaCallActionSheet(); + }); +}); diff --git a/app/lib/hooks/useNewMediaCall/useNewMediaCall.tsx b/app/lib/hooks/useNewMediaCall/useNewMediaCall.tsx new file mode 100644 index 00000000000..abc96721cb8 --- /dev/null +++ b/app/lib/hooks/useNewMediaCall/useNewMediaCall.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { NewMediaCall } from '../../../containers/NewMediaCall'; +import { showActionSheetRef } from '../../../containers/ActionSheet'; +import { getUidDirectMessage } from '../../methods/helpers/helpers'; +import { usePeerAutocompleteStore } from '../../services/voip/usePeerAutocompleteStore'; +import { useSubscription } from '../useSubscription'; +import { useMediaCallPermission } from '../useMediaCallPermission'; + +export const useNewMediaCall = (rid?: string) => { + const room = useSubscription(rid); + const hasMediaCallPermission = useMediaCallPermission(); + + const openNewMediaCall = () => { + if (room) { + const otherUserId = getUidDirectMessage(room); + if (otherUserId) { + usePeerAutocompleteStore.getState().setSelectedPeer({ type: 'user', value: otherUserId, label: room.name }); + } + } + showActionSheetRef({ + children: + }); + }; + + return { openNewMediaCall, hasMediaCallPermission }; +}; diff --git a/app/lib/hooks/useSubscription.test.ts b/app/lib/hooks/useSubscription.test.ts new file mode 100644 index 00000000000..4d93b1a6164 --- /dev/null +++ b/app/lib/hooks/useSubscription.test.ts @@ -0,0 +1,109 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +import { useSubscription } from './useSubscription'; +import { getSubscriptionByRoomId } from '../database/services/Subscription'; + +jest.mock('../database/services/Subscription', () => ({ + getSubscriptionByRoomId: jest.fn() +})); + +const mockedGetSubscriptionByRoomId = jest.mocked(getSubscriptionByRoomId); + +const createDeferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise(res => { + resolve = res; + }); + + return { promise, resolve }; +}; + +describe('useSubscription', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return undefined and skip loading when rid is missing', () => { + const { result } = renderHook(() => useSubscription()); + + expect(result.current).toBeUndefined(); + expect(mockedGetSubscriptionByRoomId).not.toHaveBeenCalled(); + }); + + it('should load subscription for the provided rid', async () => { + const subscription = { id: 'sub-1', rid: 'room-1' } as any; + mockedGetSubscriptionByRoomId.mockResolvedValue(subscription); + + const { result } = renderHook(() => useSubscription('room-1')); + + await waitFor(() => expect(result.current).toBe(subscription)); + expect(mockedGetSubscriptionByRoomId).toHaveBeenCalledWith('room-1'); + }); + + it('should keep the current subscription while loading the next rid', async () => { + const firstSubscription = { id: 'sub-1', rid: 'room-1' } as any; + const secondSubscription = { id: 'sub-2', rid: 'room-2' } as any; + const secondRequest = createDeferred(); + + mockedGetSubscriptionByRoomId + .mockResolvedValueOnce(firstSubscription) + .mockImplementationOnce(() => secondRequest.promise); + + const { result, rerender } = renderHook(({ rid }: { rid?: string }) => useSubscription(rid), { + initialProps: { rid: 'room-1' } + }); + + await waitFor(() => expect(result.current).toBe(firstSubscription)); + + rerender({ rid: 'room-2' }); + + expect(result.current).toBe(firstSubscription); + + await act(async () => { + secondRequest.resolve(secondSubscription); + await secondRequest.promise; + }); + + await waitFor(() => expect(result.current).toBe(secondSubscription)); + }); + + it('should return undefined when the lookup does not find a subscription', async () => { + mockedGetSubscriptionByRoomId.mockResolvedValue(null); + + const { result } = renderHook(() => useSubscription('room-1')); + + await waitFor(() => expect(mockedGetSubscriptionByRoomId).toHaveBeenCalledWith('room-1')); + expect(result.current).toBeUndefined(); + }); + + it('should ignore stale requests when rid changes', async () => { + const firstSubscription = { id: 'sub-1', rid: 'room-1' } as any; + const secondSubscription = { id: 'sub-2', rid: 'room-2' } as any; + const firstRequest = createDeferred(); + const secondRequest = createDeferred(); + + mockedGetSubscriptionByRoomId + .mockImplementationOnce(() => firstRequest.promise) + .mockImplementationOnce(() => secondRequest.promise); + + const { result, rerender } = renderHook(({ rid }: { rid?: string }) => useSubscription(rid), { + initialProps: { rid: 'room-1' } + }); + + rerender({ rid: 'room-2' }); + + await act(async () => { + firstRequest.resolve(firstSubscription); + await firstRequest.promise; + }); + + expect(result.current).toBeUndefined(); + + await act(async () => { + secondRequest.resolve(secondSubscription); + await secondRequest.promise; + }); + + await waitFor(() => expect(result.current).toBe(secondSubscription)); + }); +}); diff --git a/app/lib/hooks/useSubscription.ts b/app/lib/hooks/useSubscription.ts new file mode 100644 index 00000000000..c48683eee31 --- /dev/null +++ b/app/lib/hooks/useSubscription.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +import { type TSubscriptionModel } from '../../definitions'; +import { getSubscriptionByRoomId } from '../database/services/Subscription'; + +export const useSubscription = (rid?: string): TSubscriptionModel | undefined => { + 'use memo'; + + const [subscription, setSubscription] = useState(); + useEffect(() => { + let isActive = true; + + const load = async () => { + if (!rid) return; + const result = await getSubscriptionByRoomId(rid); + if (isActive) { + setSubscription(result ?? undefined); + } + }; + load(); + + return () => { + isActive = false; + }; + }, [rid]); + + return subscription; +}; diff --git a/app/lib/services/voip/getPeerAutocompleteOptions.ts b/app/lib/services/voip/getPeerAutocompleteOptions.ts new file mode 100644 index 00000000000..e54d2c78efc --- /dev/null +++ b/app/lib/services/voip/getPeerAutocompleteOptions.ts @@ -0,0 +1,91 @@ +import { usersAutoComplete } from '../restApi'; +import { store as reduxStore } from '../../store/auxStore'; + +export type TPeerItem = + | { type: 'user'; value: string; label: string; username?: string; callerId?: string } + | { type: 'sip'; value: string; label: string }; + +type TUserAutocompleteResponse = { + success?: boolean; + items?: Array<{ + _id: string; + name?: string; + username?: string; + freeSwitchExtension?: string; + }>; +}; + +const getExtensionFromPeerInfo = (peerInfo?: TPeerItem | null): string | undefined => { + if (!peerInfo || !('callerId' in peerInfo)) { + return undefined; + } + + return peerInfo.callerId; +}; + +const getPeerUsername = (peerInfo?: TPeerItem | null): string | undefined => { + if (!peerInfo || !('username' in peerInfo)) { + return undefined; + } + + return peerInfo.username; +}; + +// TODO: hook? +export const getPeerAutocompleteOptions = async ({ + filter, + peerInfo +}: { + filter: string; + peerInfo?: TPeerItem | null; +}): Promise => { + const term = filter.trim(); + if (!term) { + return []; + } + + const currentUsername = reduxStore.getState().login.user.username as string | undefined; + const peerUsername = getPeerUsername(peerInfo); + const peerExtension = getExtensionFromPeerInfo(peerInfo); + const forceSIPRouting = Boolean(reduxStore.getState().settings.VoIP_TeamCollab_SIP_Integration_For_Internal_Calls); + + const conditions = + peerExtension || forceSIPRouting + ? { + $and: [ + forceSIPRouting ? { freeSwitchExtension: { $exists: true } } : null, + peerExtension ? { freeSwitchExtension: { $ne: peerExtension } } : null + ].filter(Boolean) + } + : undefined; + + const exceptions = [currentUsername, peerUsername].filter(Boolean); + const selector = { + term, + exceptions, + ...(conditions && { conditions }) + }; + + const response = (await usersAutoComplete(selector)) as TUserAutocompleteResponse; + const canUseItems = response?.success ?? true; + + const userOptions: TPeerItem[] = canUseItems + ? (response?.items || []) + .filter(item => !!item?._id && !!(item.name || item.username)) + .map(item => ({ + type: 'user', + value: item._id, + label: item.name || item.username || '', + username: item.username, + callerId: item.freeSwitchExtension + })) + : []; + + const sipOption: TPeerItem = { + type: 'sip', + value: term, + label: term + }; + + return [sipOption, ...userOptions]; +}; diff --git a/app/lib/services/voip/usePeerAutocompleteStore.test.ts b/app/lib/services/voip/usePeerAutocompleteStore.test.ts new file mode 100644 index 00000000000..b0e2e6e01bd --- /dev/null +++ b/app/lib/services/voip/usePeerAutocompleteStore.test.ts @@ -0,0 +1,150 @@ +import { renderHook, act } from '@testing-library/react-native'; + +import { usePeerAutocompleteStore } from './usePeerAutocompleteStore'; +import type { TPeerItem } from './getPeerAutocompleteOptions'; + +const mockGetPeerAutocompleteOptions = jest.fn(); + +jest.mock('./getPeerAutocompleteOptions', () => ({ + getPeerAutocompleteOptions: (args: unknown) => mockGetPeerAutocompleteOptions(args) +})); + +const userPeer: TPeerItem = { + type: 'user', + value: 'user-1', + label: 'Alice Johnson', + username: 'alice.johnson' +}; + +const mockOptions: TPeerItem[] = [ + userPeer, + { + type: 'sip', + value: '+5511999999999', + label: '+55 11 99999-9999' + } +]; + +describe('usePeerAutocompleteStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + usePeerAutocompleteStore.setState({ + options: [], + selectedPeer: null, + filter: '' + }); + mockGetPeerAutocompleteOptions.mockResolvedValue(mockOptions); + }); + + describe('setSelectedPeer', () => { + it('should set selected peer', () => { + const { result } = renderHook(() => usePeerAutocompleteStore()); + + act(() => { + result.current.setSelectedPeer(userPeer); + }); + + expect(usePeerAutocompleteStore.getState().selectedPeer).toEqual(userPeer); + }); + + it('should clear selected peer when null is passed', () => { + usePeerAutocompleteStore.setState({ selectedPeer: userPeer }); + const { result } = renderHook(() => usePeerAutocompleteStore()); + + act(() => { + result.current.setSelectedPeer(null); + }); + + expect(usePeerAutocompleteStore.getState().selectedPeer).toBeNull(); + }); + }); + + describe('setFilter', () => { + it('should set filter value', () => { + const { result } = renderHook(() => usePeerAutocompleteStore()); + + act(() => { + result.current.setFilter('alice'); + }); + + expect(usePeerAutocompleteStore.getState().filter).toBe('alice'); + }); + }); + + describe('clearSelection', () => { + it('should clear selected peer', () => { + usePeerAutocompleteStore.setState({ selectedPeer: userPeer }); + const { result } = renderHook(() => usePeerAutocompleteStore()); + + act(() => { + result.current.clearSelection(); + }); + + expect(usePeerAutocompleteStore.getState().selectedPeer).toBeNull(); + }); + }); + + describe('fetchOptions', () => { + it('should set empty options when filter is empty', async () => { + const { result } = renderHook(() => usePeerAutocompleteStore()); + + await act(async () => { + await result.current.fetchOptions(''); + }); + + expect(usePeerAutocompleteStore.getState().options).toEqual([]); + expect(mockGetPeerAutocompleteOptions).not.toHaveBeenCalled(); + }); + + it('should set empty options when filter is only whitespace', async () => { + const { result } = renderHook(() => usePeerAutocompleteStore()); + + await act(async () => { + await result.current.fetchOptions(' '); + }); + + expect(usePeerAutocompleteStore.getState().options).toEqual([]); + expect(mockGetPeerAutocompleteOptions).not.toHaveBeenCalled(); + }); + + it('should fetch and set options when filter has value', async () => { + const { result } = renderHook(() => usePeerAutocompleteStore()); + + await act(async () => { + await result.current.fetchOptions('alice'); + }); + + expect(mockGetPeerAutocompleteOptions).toHaveBeenCalledWith({ + filter: 'alice', + peerInfo: null + }); + expect(usePeerAutocompleteStore.getState().options).toEqual(mockOptions); + }); + + it('should pass selected peer to getPeerAutocompleteOptions', async () => { + usePeerAutocompleteStore.setState({ selectedPeer: userPeer }); + const { result } = renderHook(() => usePeerAutocompleteStore()); + + await act(async () => { + await result.current.fetchOptions('bob'); + }); + + expect(mockGetPeerAutocompleteOptions).toHaveBeenCalledWith({ + filter: 'bob', + peerInfo: userPeer + }); + }); + + it('should set empty options on fetch error', async () => { + mockGetPeerAutocompleteOptions.mockRejectedValue(new Error('API error')); + + const { result } = renderHook(() => usePeerAutocompleteStore()); + + await act(async () => { + await result.current.fetchOptions('alice'); + }); + + expect(usePeerAutocompleteStore.getState().options).toEqual([]); + }); + }); +}); diff --git a/app/lib/services/voip/usePeerAutocompleteStore.ts b/app/lib/services/voip/usePeerAutocompleteStore.ts new file mode 100644 index 00000000000..e34ead9f56d --- /dev/null +++ b/app/lib/services/voip/usePeerAutocompleteStore.ts @@ -0,0 +1,61 @@ +import { create } from 'zustand'; + +import { getPeerAutocompleteOptions, type TPeerItem } from './getPeerAutocompleteOptions'; + +type TPeerAutocompleteState = { + options: TPeerItem[]; + selectedPeer: TPeerItem | null; + filter: string; +}; + +type TPeerAutocompleteActions = { + fetchOptions: (filter: string) => Promise; + setSelectedPeer: (peer: TPeerItem | null) => void; + setFilter: (filter: string) => void; + clearSelection: () => void; + reset: () => void; +}; + +export type TPeerAutocompleteStore = TPeerAutocompleteState & TPeerAutocompleteActions; + +export const usePeerAutocompleteStore = create((set, get) => ({ + options: [], + selectedPeer: null, + filter: '', + + fetchOptions: async (filter: string) => { + const term = filter.trim(); + if (!term) { + set({ options: [] }); + return; + } + + try { + const currentPeer = get().selectedPeer; + const options = await getPeerAutocompleteOptions({ + filter: term, + peerInfo: currentPeer + }); + + set({ options }); + } catch { + set({ options: [] }); + } + }, + + setSelectedPeer: (peer: TPeerItem | null) => { + set({ selectedPeer: peer, filter: '', options: [] }); + }, + + setFilter: (filter: string) => { + set({ filter }); + }, + + clearSelection: () => { + set({ selectedPeer: null }); + }, + + reset: () => { + set({ options: [], selectedPeer: null, filter: '' }); + } +})); diff --git a/app/views/DiscussionsView/__snapshots__/Item.test.tsx.snap b/app/views/DiscussionsView/__snapshots__/Item.test.tsx.snap index dcca4852073..8bf4dd5d733 100644 --- a/app/views/DiscussionsView/__snapshots__/Item.test.tsx.snap +++ b/app/views/DiscussionsView/__snapshots__/Item.test.tsx.snap @@ -2237,7 +2237,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` "textAlign": "left", }, { - "color": "#E4E7EA", + "color": "#C1C7D0", "lineHeight": undefined, }, { diff --git a/app/views/NewMessageView/Item.test.tsx b/app/views/NewMessageView/Item.test.tsx index 80af27bf1b2..11e834246f7 100644 --- a/app/views/NewMessageView/Item.test.tsx +++ b/app/views/NewMessageView/Item.test.tsx @@ -7,12 +7,20 @@ import { mockedStore } from '../../reducers/mockedStore'; import { setUser } from '../../actions/login'; import * as stories from './Item.stories'; import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots'; +import { NewMediaCall } from '../../containers/NewMediaCall'; -const mockStartCall = jest.fn(); +const mockShowActionSheetRef = jest.fn(); +const mockSetSelectedPeer = jest.fn(); -jest.mock('../../lib/services/voip/MediaSessionInstance', () => ({ - mediaSessionInstance: { - startCall: (userId: string, actor: string) => mockStartCall(userId, actor) +jest.mock('../../containers/ActionSheet', () => ({ + showActionSheetRef: (params: unknown) => mockShowActionSheetRef(params) +})); + +jest.mock('../../lib/services/voip/usePeerAutocompleteStore', () => ({ + usePeerAutocompleteStore: { + getState: () => ({ + setSelectedPeer: mockSetSelectedPeer + }) } })); @@ -22,6 +30,10 @@ jest.mock('../../lib/hooks/useMediaCallPermission', () => ({ useMediaCallPermission: () => mockUseMediaCallPermission() })); +jest.mock('../../containers/NewMediaCall', () => ({ + NewMediaCall: jest.fn(() => null) +})); + const Wrapper = ({ children }: { children: React.ReactNode }) => {children}; describe('NewMessageView Item', () => { @@ -34,13 +46,7 @@ describe('NewMessageView Item', () => { it('should render correctly', () => { const { getByTestId } = render( - {}} - testID='new-message-view-item-john.doe' - /> + {}} testID='new-message-view-item-john.doe' /> ); expect(getByTestId('new-message-view-item-john.doe')).toBeTruthy(); @@ -49,13 +55,7 @@ describe('NewMessageView Item', () => { it('should display the name', () => { const { getByText } = render( - {}} - testID='new-message-view-item-john.doe' - /> + {}} testID='new-message-view-item-john.doe' /> ); expect(getByText('John Doe')).toBeTruthy(); @@ -96,67 +96,53 @@ describe('NewMessageView Item', () => { expect(onLongPressMock).toHaveBeenCalledTimes(1); }); - it('should render call button and call startCall when hasMediaCallPermission is true', () => { + it('should render call button and open new media call flow when hasMediaCallPermission is true', () => { mockUseMediaCallPermission.mockReturnValue(true); const { getByTestId } = render( - {}} - testID='new-message-view-item-john.doe' - /> + {}} testID='new-message-view-item-john.doe' /> ); fireEvent.press(getByTestId('new-message-view-item-john.doe-call')); - expect(mockStartCall).toHaveBeenCalledTimes(1); - expect(mockStartCall).toHaveBeenCalledWith('user123', 'user'); + expect(mockSetSelectedPeer).toHaveBeenCalledTimes(1); + expect(mockSetSelectedPeer).toHaveBeenCalledWith({ + type: 'user', + value: 'user123', + username: 'john.doe', + label: 'John Doe' + }); + expect(mockShowActionSheetRef).toHaveBeenCalledTimes(1); + const [actionSheetArgs] = mockShowActionSheetRef.mock.calls[0]; + expect(React.isValidElement(actionSheetArgs.children)).toBe(true); + expect(actionSheetArgs.children.type).toBe(NewMediaCall); }); it('should not render call button when hasMediaCallPermission is false', () => { mockUseMediaCallPermission.mockReturnValue(false); const { queryByTestId } = render( - {}} - testID='new-message-view-item-john.doe' - /> + {}} testID='new-message-view-item-john.doe' /> ); expect(queryByTestId('new-message-view-item-john.doe-call')).toBeNull(); }); - it('should not call startCall when userId is falsy and call button is pressed', () => { + it('should not open call flow when userId is falsy and call button is pressed', () => { mockUseMediaCallPermission.mockReturnValue(true); const { getByTestId } = render( - {}} - testID='new-message-view-item-john.doe' - /> + {}} testID='new-message-view-item-john.doe' /> ); fireEvent.press(getByTestId('new-message-view-item-john.doe-call')); - expect(mockStartCall).not.toHaveBeenCalled(); + expect(mockSetSelectedPeer).not.toHaveBeenCalled(); + expect(mockShowActionSheetRef).not.toHaveBeenCalled(); }); it('should have correct accessibility label', () => { const { getByLabelText } = render( - {}} - testID='new-message-view-item-john.doe' - /> + {}} testID='new-message-view-item-john.doe' /> ); expect(getByLabelText('John Doe')).toBeTruthy(); @@ -166,13 +152,7 @@ describe('NewMessageView Item', () => { mockUseMediaCallPermission.mockReturnValue(false); const { toJSON } = render( - {}} - testID='new-message-view-item-john.doe' - /> + {}} testID='new-message-view-item-john.doe' /> ); expect(toJSON()).toMatchSnapshot(); diff --git a/app/views/NewMessageView/Item.tsx b/app/views/NewMessageView/Item.tsx index 0228b22b7d0..7af100e119e 100644 --- a/app/views/NewMessageView/Item.tsx +++ b/app/views/NewMessageView/Item.tsx @@ -6,37 +6,11 @@ import Avatar from '../../containers/Avatar'; import { CustomIcon } from '../../containers/CustomIcon'; import sharedStyles from '../Styles'; import { useTheme } from '../../theme'; -import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstance'; import I18n from '../../i18n'; import { useMediaCallPermission } from '../../lib/hooks/useMediaCallPermission'; - -const styles = StyleSheet.create({ - button: { - height: 54 - }, - container: { - flexDirection: 'row' - }, - avatar: { - marginHorizontal: 12, - marginVertical: 12 - }, - textContainer: { - flex: 1, - flexDirection: 'column', - justifyContent: 'center', - marginRight: 12 - }, - name: { - fontSize: 18, - lineHeight: 26, - ...sharedStyles.textMedium - }, - iconContainer: { - paddingHorizontal: 15, - alignSelf: 'center' - } -}); +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; +import { showActionSheetRef } from '../../containers/ActionSheet'; +import { NewMediaCall } from '../../containers/NewMediaCall'; interface IItem { userId: string; @@ -53,7 +27,10 @@ const Item = ({ userId, name, username, onPress, testID, onLongPress }: IItem) = const handleCallPress = () => { if (!userId) return; - mediaSessionInstance.startCall(userId, 'user'); + usePeerAutocompleteStore.getState().setSelectedPeer({ type: 'user', value: userId, label: name, username }); + showActionSheetRef({ + children: + }); }; return ( @@ -90,3 +67,31 @@ const Item = ({ userId, name, username, onPress, testID, onLongPress }: IItem) = }; export default Item; + +const styles = StyleSheet.create({ + button: { + height: 54 + }, + container: { + flexDirection: 'row' + }, + avatar: { + marginHorizontal: 12, + marginVertical: 12 + }, + textContainer: { + flex: 1, + flexDirection: 'column', + justifyContent: 'center', + marginRight: 12 + }, + name: { + fontSize: 18, + lineHeight: 26, + ...sharedStyles.textMedium + }, + iconContainer: { + paddingHorizontal: 15, + alignSelf: 'center' + } +}); diff --git a/app/views/RoomActionsView/components/CallSection.tsx b/app/views/RoomActionsView/components/CallSection.tsx index 4d0707a52d5..2d385e44b8a 100644 --- a/app/views/RoomActionsView/components/CallSection.tsx +++ b/app/views/RoomActionsView/components/CallSection.tsx @@ -2,9 +2,8 @@ import React from 'react'; import * as List from '../../../containers/List'; import { useVideoConf } from '../../../lib/hooks/useVideoConf'; -import { mediaSessionInstance } from '../../../lib/services/voip/MediaSessionInstance'; import type { TSubscriptionModel } from '../../../definitions'; -import { useMediaCallPermission } from '../../../lib/hooks/useMediaCallPermission'; +import { useNewMediaCall } from '../../../lib/hooks/useNewMediaCall'; export default function CallSection({ room, @@ -14,12 +13,7 @@ export default function CallSection({ disabled: boolean; }): React.ReactElement | null { const { callEnabled, showInitCallActionSheet, disabledTooltip } = useVideoConf(room.rid); - const hasMediaCallPermission = useMediaCallPermission(); - - const handleVoiceCallPress = () => { - if (!room) return; - mediaSessionInstance.startCallByRoom(room); - }; + const { openNewMediaCall, hasMediaCallPermission } = useNewMediaCall(room.rid); if (!hasMediaCallPermission && !callEnabled) { return null; @@ -32,7 +26,7 @@ export default function CallSection({ <> } showActionIndicator diff --git a/app/views/RoomInfoView/components/BaseButton.tsx b/app/views/RoomInfoView/components/BaseButton.tsx new file mode 100644 index 00000000000..7174012a8ab --- /dev/null +++ b/app/views/RoomInfoView/components/BaseButton.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { BorderlessButton } from 'react-native-gesture-handler'; + +import { CustomIcon, type TIconsName } from '../../../containers/CustomIcon'; +import { useTheme } from '../../../theme'; +import styles from '../styles'; + +export function BaseButton({ + danger, + iconName, + onPress, + label, + showIcon = true, + enabled = true +}: { + danger?: boolean; + iconName: TIconsName; + onPress?: (prop: any) => void; + label: string; + showIcon?: boolean; + enabled?: boolean; +}): React.ReactElement | null { + const { colors } = useTheme(); + const color = danger ? colors.buttonBackgroundDangerDefault : colors.fontHint; + + if (showIcon) + return ( + + + + {label} + + + ); + return null; +} diff --git a/app/views/RoomInfoView/components/RoomInfoButtons.test.tsx b/app/views/RoomInfoView/components/RoomInfoButtons.test.tsx index 877f3e97eb3..00740c9171a 100644 --- a/app/views/RoomInfoView/components/RoomInfoButtons.test.tsx +++ b/app/views/RoomInfoView/components/RoomInfoButtons.test.tsx @@ -11,17 +11,18 @@ import { SubscriptionType } from '../../../definitions'; const mockShowInitCallActionSheet = jest.fn(); const mockShowActionSheet = jest.fn(); -const mockStartCallByRoom = jest.fn(); +const mockOpenNewMediaCall = jest.fn(); +const noopOpenNewMediaCall = () => undefined; const mockUseVideoConf = jest.fn(); -const mockUseMediaCallPermission = jest.fn(); +const mockUseNewMediaCall = jest.fn(); jest.mock('../../../lib/hooks/useVideoConf', () => ({ useVideoConf: (...args: unknown[]) => mockUseVideoConf(...args) })); -jest.mock('../../../lib/hooks/useMediaCallPermission', () => ({ - useMediaCallPermission: (...args: unknown[]) => mockUseMediaCallPermission(...args) +jest.mock('../../../lib/hooks/useNewMediaCall', () => ({ + useNewMediaCall: (...args: unknown[]) => mockUseNewMediaCall(...args) })); jest.mock('../hooks', () => ({ @@ -33,12 +34,6 @@ jest.mock('../../../containers/ActionSheet', () => ({ useActionSheet: () => ({ showActionSheet: mockShowActionSheet }) })); -jest.mock('../../../lib/services/voip/MediaSessionInstance', () => ({ - mediaSessionInstance: { - startCallByRoom: (room: ISubscription) => mockStartCallByRoom(room) - } -})); - const Wrapper = ({ children }: { children: React.ReactNode }) => {children}; const createMockRoom = (overrides: Partial = {}): ISubscription => @@ -90,7 +85,10 @@ describe('RoomInfoButtons', () => { disabledTooltip: false, showInitCallActionSheet: mockShowInitCallActionSheet }); - mockUseMediaCallPermission.mockReturnValue(true); + mockUseNewMediaCall.mockReturnValue({ + openNewMediaCall: noopOpenNewMediaCall, + hasMediaCallPermission: true + }); }); it('should render Message button', () => { @@ -113,16 +111,18 @@ describe('RoomInfoButtons', () => { expect(handleCreateDirectMessage).toHaveBeenCalledTimes(1); }); - it('should call mediaSessionInstance.startCallByRoom when Voice call is pressed', () => { - const mockRoom = createMockRoom(); + it('should call openNewMediaCall when Voice call is pressed', () => { + mockUseNewMediaCall.mockReturnValue({ + openNewMediaCall: mockOpenNewMediaCall, + hasMediaCallPermission: true + }); const { getByTestId } = render( - + ); fireEvent.press(getByTestId('room-info-view-phone')); - expect(mockStartCallByRoom).toHaveBeenCalledTimes(1); - expect(mockStartCallByRoom).toHaveBeenCalledWith(mockRoom); + expect(mockOpenNewMediaCall).toHaveBeenCalledTimes(1); }); it('should call showInitCallActionSheet when Video call is pressed', () => { @@ -135,8 +135,11 @@ describe('RoomInfoButtons', () => { expect(mockShowInitCallActionSheet).toHaveBeenCalledTimes(1); }); - it('should not render Voice call when useMediaCallPermission returns false', () => { - mockUseMediaCallPermission.mockReturnValue(false); + it('should not render Voice call when hasMediaCallPermission is false', () => { + mockUseNewMediaCall.mockReturnValue({ + openNewMediaCall: noopOpenNewMediaCall, + hasMediaCallPermission: false + }); const { queryByTestId } = render( diff --git a/app/views/RoomInfoView/components/RoomInfoButtons.tsx b/app/views/RoomInfoView/components/RoomInfoButtons.tsx index d04b966b9aa..33396816579 100644 --- a/app/views/RoomInfoView/components/RoomInfoButtons.tsx +++ b/app/views/RoomInfoView/components/RoomInfoButtons.tsx @@ -1,19 +1,17 @@ import React from 'react'; -import { Text, View } from 'react-native'; -import { BorderlessButton } from 'react-native-gesture-handler'; +import { View } from 'react-native'; -import { CustomIcon, type TIconsName } from '../../../containers/CustomIcon'; +import { type TIconsName } from '../../../containers/CustomIcon'; import { type ISubscription, SubscriptionType } from '../../../definitions'; import i18n from '../../../i18n'; import { useVideoConf } from '../../../lib/hooks/useVideoConf'; -import { useTheme } from '../../../theme'; import styles from '../styles'; import { compareServerVersion } from '../../../lib/methods/helpers'; import { useE2EEWarning } from '../hooks'; import { useActionSheet } from '../../../containers/ActionSheet'; import type { TActionSheetOptionsItem } from '../../../containers/ActionSheet'; -import { mediaSessionInstance } from '../../../lib/services/voip/MediaSessionInstance'; -import { useMediaCallPermission } from '../../../lib/hooks/useMediaCallPermission'; +import { BaseButton } from './BaseButton'; +import { useNewMediaCall } from '../../../lib/hooks/useNewMediaCall'; type ButtonConfig = { label: string; @@ -24,36 +22,6 @@ type ButtonConfig = { show: boolean; }; -function BaseButton({ - danger, - iconName, - onPress, - label, - showIcon = true, - enabled = true -}: { - danger?: boolean; - iconName: TIconsName; - onPress?: (prop: any) => void; - label: string; - showIcon?: boolean; - enabled?: boolean; -}): React.ReactElement | null { - const { colors } = useTheme(); - const color = danger ? colors.buttonBackgroundDangerDefault : colors.fontHint; - - if (showIcon) - return ( - - - - {label} - - - ); - return null; -} - interface IRoomInfoButtons { rid: string; room: ISubscription | undefined; @@ -88,7 +56,7 @@ export const RoomInfoButtons = ({ const room = roomFromRid || roomFromProps; const { showActionSheet } = useActionSheet(); const { callEnabled, disabledTooltip, showInitCallActionSheet } = useVideoConf(rid); - const hasMediaCallPermission = useMediaCallPermission(); + const { openNewMediaCall, hasMediaCallPermission } = useNewMediaCall(rid); // Following the web behavior, when is a DM with myself, shouldn't appear block or ignore option const isDmWithMyself = room?.uids?.filter((uid: string) => uid !== roomUserId).length === 0; @@ -104,11 +72,6 @@ export const RoomInfoButtons = ({ !itsMe && isDirectFromSaved && !isDmWithMyself && compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '6.4.0'); const renderVideoCall = !hasE2EEWarning && callEnabled && !roomFromRid; - const handleVoiceCall = () => { - if (!room) return; - mediaSessionInstance.startCallByRoom(room); - }; - const allItems: ButtonConfig[] = [ { label: i18n.t('Message'), @@ -120,7 +83,7 @@ export const RoomInfoButtons = ({ { label: i18n.t('Voice_call'), iconName: 'phone', - onPress: handleVoiceCall, + onPress: openNewMediaCall, enabled: true, show: hasMediaCallPermission }, diff --git a/app/views/RoomInfoView/components/__snapshots__/RoomInfoABAC.test.tsx.snap b/app/views/RoomInfoView/components/__snapshots__/RoomInfoABAC.test.tsx.snap index 50b4c7d7237..d6ddfd9eef4 100644 --- a/app/views/RoomInfoView/components/__snapshots__/RoomInfoABAC.test.tsx.snap +++ b/app/views/RoomInfoView/components/__snapshots__/RoomInfoABAC.test.tsx.snap @@ -991,7 +991,7 @@ exports[`Story Snapshots: Dark should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 16, }, [ @@ -1089,7 +1089,7 @@ exports[`Story Snapshots: Dark should match snapshot 1`] = ` "textAlign": "left", }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -1244,7 +1244,7 @@ exports[`Story Snapshots: Dark should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 16, }, [ @@ -1342,7 +1342,7 @@ exports[`Story Snapshots: Dark should match snapshot 1`] = ` "textAlign": "left", }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -1739,7 +1739,7 @@ exports[`Story Snapshots: Dark should match snapshot 1`] = ` "textAlign": "left", }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } diff --git a/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap b/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap index 642a5d044af..d2bdb8a706a 100644 --- a/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap +++ b/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap @@ -2794,7 +2794,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` "textAlign": "left", }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -2820,7 +2820,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` "lineHeight": 22, }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -2966,7 +2966,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` "textAlign": "left", }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -2992,7 +2992,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` "lineHeight": 22, }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -3145,7 +3145,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` "textAlign": "left", }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -3171,7 +3171,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` "lineHeight": 22, }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -3680,7 +3680,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` "textAlign": "left", }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -3706,7 +3706,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` "lineHeight": 22, }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -3852,7 +3852,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` "textAlign": "left", }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -3878,7 +3878,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` "lineHeight": 22, }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -4031,7 +4031,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` "textAlign": "left", }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -4057,7 +4057,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` "lineHeight": 22, }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -4400,7 +4400,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` "textAlign": "left", }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } @@ -4426,7 +4426,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` "lineHeight": 22, }, { - "color": "#E4E7EA", + "color": "#C1C7D0", }, ] } diff --git a/app/views/RoomView/RightButtons.tsx b/app/views/RoomView/RightButtons.tsx index a389453ff6b..2ad9a83367e 100644 --- a/app/views/RoomView/RightButtons.tsx +++ b/app/views/RoomView/RightButtons.tsx @@ -528,7 +528,6 @@ class RightButtonsContainer extends Component {threadsEnabled ? ( { 'use memo'; const { showInitCallActionSheet, callEnabled, disabledTooltip } = useVideoConf(rid); - const hasMediaCallPermission = useMediaCallPermission(); + const { openNewMediaCall, hasMediaCallPermission } = useNewMediaCall(rid); if (hasMediaCallPermission) { - const handlePress = () => { - if (!room) return; - mediaSessionInstance.startCallByRoom(room); - }; - return ( ); diff --git a/app/views/SidebarView/components/Stacks.tsx b/app/views/SidebarView/components/Stacks.tsx index 556c74d6921..78bab8fe79d 100644 --- a/app/views/SidebarView/components/Stacks.tsx +++ b/app/views/SidebarView/components/Stacks.tsx @@ -2,15 +2,14 @@ import { useTheme } from '../../../theme'; import * as List from '../../../containers/List'; import { sidebarNavigate } from '../methods/sidebarNavigate'; import { useAppSelector } from '../../../lib/hooks/useAppSelector'; -import { simulateCall } from '../../../lib/services/voip/simulateCall'; -import { useCallStore } from '../../../lib/services/voip/useCallStore'; +import { useNewMediaCall } from '../../../lib/hooks/useNewMediaCall'; const Stacks = ({ currentScreen }: { currentScreen: string | null }) => { 'use memo'; const { colors } = useTheme(); const isMasterDetail = useAppSelector(state => state.app.isMasterDetail); - const toggleFocus = useCallStore(state => state.toggleFocus); + const { openNewMediaCall, hasMediaCallPermission } = useNewMediaCall(); if (isMasterDetail) { return null; @@ -26,17 +25,17 @@ const Stacks = ({ currentScreen }: { currentScreen: string | null }) => { testID='sidebar-chats' /> - } - onPress={() => { - simulateCall(); - toggleFocus(); - }} - // backgroundColor={currentScreen === 'ChatsStackNavigator' ? colors.strokeLight : undefined} - testID='sidebar-voice-call' - /> - + {hasMediaCallPermission ? ( + <> + } + onPress={openNewMediaCall} + testID='sidebar-media-call' + /> + + + ) : null} } diff --git a/app/views/ThreadMessagesView/__snapshots__/Item.test.tsx.snap b/app/views/ThreadMessagesView/__snapshots__/Item.test.tsx.snap index abad0d4166c..608ca055fdf 100644 --- a/app/views/ThreadMessagesView/__snapshots__/Item.test.tsx.snap +++ b/app/views/ThreadMessagesView/__snapshots__/Item.test.tsx.snap @@ -4438,7 +4438,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` "textAlign": "left", }, { - "color": "#E4E7EA", + "color": "#C1C7D0", "lineHeight": undefined, }, { @@ -4504,7 +4504,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 24, }, [ @@ -4561,7 +4561,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 24, }, [ @@ -4652,7 +4652,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` style={ [ { - "color": "#E4E7EA", + "color": "#C1C7D0", "fontSize": 24, }, [