diff --git a/app/lib/methods/helpers/log/events.ts b/app/lib/methods/helpers/log/events.ts index 7817983f2db..8952e03379c 100644 --- a/app/lib/methods/helpers/log/events.ts +++ b/app/lib/methods/helpers/log/events.ts @@ -229,6 +229,7 @@ export default { ROOM_TOGGLE_FOLLOW_THREADS: 'room_toggle_follow_threads', ROOM_GO_TEAM_CHANNELS: 'room_go_team_channels', ROOM_GO_SEARCH: 'room_go_search', + ROOM_GO_E2EE: 'room_go_e2ee', ROOM_GO_THREADS: 'room_go_threads', ROOM_GO_ROOM_INFO: 'room_go_room_info', ROOM_GO_USER_INFO: 'room_go_user_info', diff --git a/app/views/RoomView/__tests__/RoomGate.test.tsx b/app/views/RoomView/__tests__/RoomGate.test.tsx index 6b0a1c07b62..d917c71f4e3 100644 --- a/app/views/RoomView/__tests__/RoomGate.test.tsx +++ b/app/views/RoomView/__tests__/RoomGate.test.tsx @@ -35,7 +35,7 @@ jest.mock('../components/RoomRouteInvalid', () => { }); jest.mock('../hooks/useHeader', () => ({ useHeader: jest.fn() })); jest.mock('../hooks/useE2EEStatus', () => ({ - useE2EEStatus: jest.fn(() => ({ showMissingE2EEKey: false, showE2EEDisabledRoom: false })) + useE2EEStatus: jest.fn(() => ({ showMissingE2EEKey: false, showE2EEDisabledRoom: false, hasE2EEWarning: false })) })); jest.mock('../../../lib/methods/isInviteSubscription', () => ({ isInviteSubscription: jest.fn(() => false) })); jest.mock('../../../lib/methods/helpers', () => ({ getUidDirectMessage: jest.fn(), getRoomTitle: jest.fn(() => 'Room Title') })); @@ -74,7 +74,7 @@ describe('RoomGate', () => { beforeEach(() => { jest.clearAllMocks(); room.current = { rid: 'rid-1', t: 'c' }; - jest.mocked(useE2EEStatus).mockReturnValue({ showMissingE2EEKey: false, showE2EEDisabledRoom: false }); + jest.mocked(useE2EEStatus).mockReturnValue({ showMissingE2EEKey: false, showE2EEDisabledRoom: false, hasE2EEWarning: false }); jest.mocked(isInviteSubscription).mockReturnValue(false); }); @@ -110,7 +110,7 @@ describe('RoomGate', () => { it('keeps the room screen unmounted while the E2EE key is missing', () => { room.current = { rid: 'rid-1', t: 'c', encrypted: true } as RoomState['room']; - jest.mocked(useE2EEStatus).mockReturnValue({ showMissingE2EEKey: true, showE2EEDisabledRoom: false }); + jest.mocked(useE2EEStatus).mockReturnValue({ showMissingE2EEKey: true, showE2EEDisabledRoom: false, hasE2EEWarning: true }); renderGate(); @@ -120,7 +120,7 @@ describe('RoomGate', () => { it('keeps the room screen unmounted while the session has E2EE disabled', () => { room.current = { rid: 'rid-1', t: 'c', encrypted: true } as RoomState['room']; - jest.mocked(useE2EEStatus).mockReturnValue({ showMissingE2EEKey: false, showE2EEDisabledRoom: true }); + jest.mocked(useE2EEStatus).mockReturnValue({ showMissingE2EEKey: false, showE2EEDisabledRoom: true, hasE2EEWarning: true }); renderGate(); diff --git a/app/views/RoomView/components/RightButtons.test.tsx b/app/views/RoomView/components/RightButtons.test.tsx deleted file mode 100644 index 59ef715bdb6..00000000000 --- a/app/views/RoomView/components/RightButtons.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { render } from '@testing-library/react-native'; - -import RightButtons from './RightButtons'; - -const mockNavigation = { navigate: jest.fn(), push: jest.fn() }; -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => mockNavigation -})); -jest.mock('../../../containers/ActionSheet', () => ({ - useActionSheet: () => ({ showActionSheet: jest.fn() }) -})); -jest.mock('../../../lib/hooks/useMasterDetail', () => ({ - ...jest.requireActual('../../../lib/hooks/useMasterDetail'), - useMasterDetail: () => false -})); -jest.mock('../../../theme', () => ({ useTheme: () => ({ colors: { fontDanger: '#f00' } }) })); -jest.mock('../../../lib/helpers/getRoomAccessibilityLabel', () => ({ __esModule: true, default: () => 'label' })); -jest.mock('../../../lib/methods/helpers', () => ({ - ...jest.requireActual('../../../lib/methods/helpers'), - getRoomTitle: () => 'Room Title', - isGroupChat: () => false -})); - -const fakeState = { - login: { user: { id: 'u1', username: 'user', token: 'tok' } }, - settings: { Threads_enabled: true, Livechat_request_comment_when_closing_conversation: false }, - troubleshootingNotification: { issuesWithNotifications: false }, - permissions: { 'toggle-room-e2e-encryption': ['perm'] } -}; -jest.mock('../../../lib/hooks/useAppSelector', () => ({ - useAppSelector: (selector: (state: typeof fakeState) => unknown) => selector(fakeState) -})); - -let mockRoomState = { - room: { rid: 'rid-1', t: 'c', name: 'general' }, - canForwardGuest: false -}; -jest.mock('zustand', () => ({ - useStore: (_store: unknown, selector: (state: typeof mockRoomState) => unknown) => selector(mockRoomState) -})); -jest.mock('../../../ee/omnichannel/hooks/useCanReturnQueue', () => ({ useCanReturnQueue: () => false })); -jest.mock('../hooks/useCanPlaceLivechatOnHold', () => ({ useCanPlaceLivechatOnHold: () => false })); - -let mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: false }; -jest.mock('../hooks/useE2EEStatus', () => ({ useE2EEStatus: () => mockE2EEStatus })); - -let mockHeaderHooks = { - isFollowingThread: false, - tunread: [] as string[], - tunreadUser: [] as string[], - tunreadGroup: [] as string[], - isSelfDm: false, - canToggleEncryption: false, - subscription: undefined -}; -jest.mock('../hooks/useThreadFollowing', () => ({ useThreadFollowing: () => mockHeaderHooks.isFollowingThread })); -jest.mock('../hooks/useSubscriptionUnreads', () => ({ - useSubscriptionUnreads: () => { - const { tunread, tunreadUser, tunreadGroup, isSelfDm, subscription } = mockHeaderHooks; - return { tunread, tunreadUser, tunreadGroup, isSelfDm, subscription }; - } -})); -jest.mock('../../../lib/hooks/usePermissions', () => ({ - usePermissions: () => [mockHeaderHooks.canToggleEncryption] -})); - -jest.mock('../../../containers/Header/components/HeaderButton', () => { - const ReactActual = jest.requireActual('react'); - return { - Container: ({ children }: any) => ReactActual.createElement('Container', null, children), - Item: (props: any) => ReactActual.createElement('Item', props), - BadgeUnread: () => null - }; -}); -jest.mock('./HeaderCallButton', () => ({ HeaderCallButton: () => null })); - -describe('RightButtons', () => { - const roomStore = {} as any; - beforeEach(() => { - jest.clearAllMocks(); - mockRoomState = { - room: { rid: 'rid-1', t: 'c', name: 'general' }, - canForwardGuest: false - }; - mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: false }; - mockHeaderHooks = { - isFollowingThread: false, - tunread: [], - tunreadUser: [], - tunreadGroup: [], - isSelfDm: false, - canToggleEncryption: false, - subscription: undefined - }; - }); - - it('renders nothing without a rid', () => { - const { toJSON } = render(); - expect(toJSON()).toBeNull(); - }); - - it('renders search and threads buttons for a regular channel', () => { - const { queryByTestId } = render(); - expect(queryByTestId('room-view-search')).toBeTruthy(); - expect(queryByTestId('room-view-header-threads')).toBeTruthy(); - }); - - it('renders the omnichannel kebab for a non-preview livechat room', () => { - mockRoomState = { ...mockRoomState, room: { rid: 'rid-1', t: 'l', name: 'chat' } as any }; - const { queryByTestId } = render(); - expect(queryByTestId('room-view-header-omnichannel-kebab')).toBeTruthy(); - }); - - it('renders the follow toggle when a tmid is present', () => { - const { queryByTestId } = render(); - expect(queryByTestId('room-view-header-follow')).toBeTruthy(); - }); - - it('renders the encryption toggle when there is an E2EE warning', () => { - mockRoomState = { ...mockRoomState, room: { rid: 'rid-1', t: 'c', encrypted: true } as any }; - mockE2EEStatus = { showMissingE2EEKey: true, showE2EEDisabledRoom: false }; - const { queryByTestId } = render(); - expect(queryByTestId('room-view-header-encryption')).toBeTruthy(); - }); -}); diff --git a/app/views/RoomView/components/RightButtons.tsx b/app/views/RoomView/components/RightButtons.tsx deleted file mode 100644 index c882cbb8ae6..00000000000 --- a/app/views/RoomView/components/RightButtons.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import { type ReactElement } from 'react'; -import { useStore } from 'zustand'; -import { useNavigation } from '@react-navigation/native'; -import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; - -import { type TActionSheetOptionsItem, useActionSheet } from '../../../containers/ActionSheet'; -import * as HeaderButton from '../../../containers/Header/components/HeaderButton'; -import { type ISubscription, type SubscriptionType, type TUserStatus } from '../../../definitions'; -import { type ILivechatDepartment } from '../../../definitions/ILivechatDepartment'; -import { type ILivechatTag } from '../../../definitions/ILivechatTag'; -import i18n from '../../../i18n'; -import { getRoomTitle, isGroupChat, showConfirmationAlert, showErrorAlert } from '../../../lib/methods/helpers'; -import { closeLivechat as closeLivechatService } from '../../../lib/methods/helpers/closeLivechat'; -import { events, logEvent } from '../../../lib/methods/helpers/log'; -import getRoomAccessibilityLabel from '../../../lib/helpers/getRoomAccessibilityLabel'; -import { useAppSelector } from '../../../lib/hooks/useAppSelector'; -import { useSetting } from '../../../lib/hooks/useSetting'; -import { useCanReturnQueue } from '../../../ee/omnichannel/hooks/useCanReturnQueue'; -import { useMasterDetail } from '../../../lib/hooks/useMasterDetail'; -import { usePermissions } from '../../../lib/hooks/usePermissions'; -import { getDepartmentInfo, getTagsList, onHoldLivechat, returnLivechat } from '../../../lib/services/restApi'; -import { getUserSelector } from '../../../selectors/login'; -import { type TNavigation } from '../../../stacks/stackType'; -import { type ChatsStackParamList } from '../../../stacks/types'; -import { useTheme } from '../../../theme'; -import { HeaderCallButton } from './HeaderCallButton'; -import { useCanPlaceLivechatOnHold } from '../hooks/useCanPlaceLivechatOnHold'; -import { useE2EEStatus } from '../hooks/useE2EEStatus'; -import { useSubscriptionUnreads } from '../hooks/useSubscriptionUnreads'; -import { useThreadFollowing } from '../hooks/useThreadFollowing'; -import { toggleFollowThread } from '../../../lib/methods/toggleFollowThread'; -import { type RoomStore } from '../definitions'; - -interface IRightButtonsProps { - rid?: string; - tmid?: string; - roomStore: RoomStore; -} - -type RightButtonsNavigation = NativeStackNavigationProp; - -type RightButtonsScreen = keyof (ChatsStackParamList & TNavigation); - -const navigateToScreen = ({ - navigation, - isMasterDetail, - screen, - params -}: { - navigation: RightButtonsNavigation; - isMasterDetail: boolean; - screen: Screen; - params?: (ChatsStackParamList & TNavigation)[Screen]; -}) => { - if (isMasterDetail) { - const navigateToModal = navigation.navigate as ( - screen: 'ModalStackNavigator', - params: { screen: Screen; params?: typeof params } - ) => void; - navigateToModal('ModalStackNavigator', { screen, params }); - return; - } - const navigateDirect: (screen: Screen, params?: (ChatsStackParamList & TNavigation)[Screen]) => void = navigation.navigate; - navigateDirect(screen, params); -}; - -const placeOnHoldLivechat = (rid: string, navigation: RightButtonsNavigation) => { - showConfirmationAlert({ - title: i18n.t('Are_you_sure_question_mark'), - message: i18n.t('Would_like_to_place_on_hold'), - confirmationText: i18n.t('Yes'), - onPress: async () => { - try { - await onHoldLivechat(rid); - navigation.navigate('RoomsListView'); - } catch (e: any) { - showErrorAlert(e.data?.error, i18n.t('Oops')); - } - } - }); -}; - -const closeLivechat = async ({ - rid, - departmentId, - isMasterDetail, - livechatRequestComment, - navigation -}: { - rid: string; - departmentId?: string; - isMasterDetail: boolean; - livechatRequestComment: boolean; - navigation: RightButtonsNavigation; -}) => { - try { - let departmentInfo: ILivechatDepartment | undefined; - let tagsList: ILivechatTag[] | undefined; - - if (departmentId) { - const result = await getDepartmentInfo(departmentId); - if (result.success) { - departmentInfo = result.department as ILivechatDepartment; - } - } - - if (departmentInfo?.requestTagBeforeClosingChat) { - tagsList = await getTagsList(); - } - - if (!livechatRequestComment && !departmentInfo?.requestTagBeforeClosingChat) { - const comment = i18n.t('Chat_closed_by_agent'); - return closeLivechatService({ rid, isMasterDetail, comment }); - } - - navigateToScreen({ - navigation, - isMasterDetail, - screen: 'CloseLivechatView', - params: { rid, departmentId, departmentInfo, tagsList } - }); - } catch { - // do nothing - } -}; - -const RightButtons = ({ rid, tmid, roomStore }: IRightButtonsProps): ReactElement | null => { - const navigation = useNavigation>(); - const isMasterDetail = useMasterDetail(); - const { colors } = useTheme(); - const { showActionSheet } = useActionSheet(); - - const userId = useAppSelector(state => getUserSelector(state).id); - const threadsEnabled = useSetting('Threads_enabled') as boolean; - const livechatRequestComment = useSetting('Livechat_request_comment_when_closing_conversation') as boolean; - const issuesWithNotifications = useAppSelector(state => state.troubleshootingNotification.issuesWithNotifications); - - const room = useStore(roomStore, s => s.room); - const canForwardGuest = useStore(roomStore, s => s.canForwardGuest); - const canReturnQueue = useCanReturnQueue(room.t === 'l'); - const canPlaceLivechatOnHold = useCanPlaceLivechatOnHold(roomStore); - - const { showMissingE2EEKey, showE2EEDisabledRoom } = useE2EEStatus(roomStore); - const hasE2EEWarning = !!('encrypted' in room && (showMissingE2EEKey || showE2EEDisabledRoom)); - - const isFollowingThread = useThreadFollowing(tmid, userId); - const { tunread, tunreadUser, tunreadGroup, isSelfDm, subscription } = useSubscriptionUnreads(roomStore, userId); - const [canToggleEncryption] = usePermissions(['toggle-room-e2e-encryption'], rid); - - const t = room.t as SubscriptionType; - const { status } = room; - const roomName = getRoomTitle(room); - const roomIsGroupChat = isGroupChat(room as ISubscription); - const teamMain = 'teamMain' in room ? room.teamMain : false; - const encrypted = 'encrypted' in room ? room.encrypted : undefined; - const departmentId = 'id' in room ? room.departmentId : undefined; - - const goThreadsView = () => { - logEvent(events.ROOM_GO_THREADS); - if (!rid) { - return; - } - navigateToScreen({ navigation, isMasterDetail, screen: 'ThreadMessagesView', params: { rid, t } }); - }; - - const handleReturnLivechat = () => { - if (rid) { - showConfirmationAlert({ - message: i18n.t('Would_you_like_to_return_the_inquiry'), - confirmationText: i18n.t('Yes'), - onPress: async () => { - try { - await returnLivechat(rid, departmentId); - } catch (e: any) { - showErrorAlert(e.reason, i18n.t('Oops')); - } - } - }); - } - }; - - const showMoreActions = () => { - logEvent(events.ROOM_SHOW_MORE_ACTIONS); - const options = [] as TActionSheetOptionsItem[]; - if (canPlaceLivechatOnHold) { - options.push({ - title: i18n.t('Place_chat_on_hold'), - icon: 'pause', - onPress: () => rid && placeOnHoldLivechat(rid, navigation) - }); - } - - if (canForwardGuest) { - options.push({ - title: i18n.t('Forward_Chat'), - icon: 'chat-forward', - onPress: () => { - if (rid) { - navigateToScreen({ navigation, isMasterDetail, screen: 'ForwardLivechatView', params: { rid } }); - } - } - }); - } - - if (canReturnQueue) { - options.push({ - title: i18n.t('Return_to_waiting_line'), - icon: 'move-to-the-queue', - onPress: () => handleReturnLivechat() - }); - } - - options.push({ - title: i18n.t('Close'), - icon: 'chat-close', - onPress: () => rid && closeLivechat({ rid, departmentId, isMasterDetail, livechatRequestComment, navigation }), - danger: true - }); - - showActionSheet({ options }); - }; - - const navigateToNotificationOrPushTroubleshoot = () => { - if (!rid || !subscription) { - return; - } - if (!issuesWithNotifications) { - navigateToScreen({ navigation, isMasterDetail, screen: 'NotificationPrefView', params: { rid, room: subscription } }); - } else { - navigateToScreen({ navigation, isMasterDetail, screen: 'PushTroubleshootView' }); - } - }; - - const goSearchView = () => { - logEvent(events.ROOM_GO_SEARCH); - if (!rid) { - return; - } - navigateToScreen({ - navigation, - isMasterDetail, - screen: 'SearchMessagesView', - params: isMasterDetail ? { rid, t, encrypted, showCloseModal: true } : { rid, t, encrypted } - }); - }; - - const goE2EEToggleRoomView = () => { - logEvent(events.ROOM_GO_SEARCH); - if (!rid) { - return; - } - navigateToScreen({ navigation, isMasterDetail, screen: 'E2EEToggleRoomView', params: { rid } }); - }; - - const onToggleFollowThread = () => { - logEvent(events.ROOM_TOGGLE_FOLLOW_THREADS); - if (tmid) { - toggleFollowThread(tmid, isFollowingThread); - } - }; - - const threadsAccessibilityLabel = () => { - if (!tunread.length) { - return i18n.t('Threads'); - } - if (tunreadUser?.length) { - return i18n.t('Threads_dm_unread', { unread: tunreadUser?.length }); - } - if (tunreadGroup?.length) { - return i18n.t('Threads_group_unread', { unread: tunreadGroup?.length }); - } - return i18n.t('Threads_unread', { unread: tunread?.length }); - }; - - const accessibilityRoomName = - !roomIsGroupChat && t === 'd' && !!userId - ? roomName - : getRoomAccessibilityLabel({ type: t, userId, isGroupChat: roomIsGroupChat, status: status as TUserStatus, teamMain }); - - if (!rid) { - return null; - } - - if (status === 'INVITED') { - return null; - } - - if (t === 'l') { - if (status !== 'queued') { - return ( - - - - ); - } - return null; - } - if (tmid) { - return ( - - - - ); - } - return ( - - {hasE2EEWarning ? ( - - ) : null} - {issuesWithNotifications || (room as ISubscription).disableNotifications ? ( - - ) : null} - {!isSelfDm ? ( - - ) : null} - {threadsEnabled ? ( - } - disabled={hasE2EEWarning} - /> - ) : null} - - - ); -}; - -export default RightButtons; diff --git a/app/views/RoomView/components/HeaderCallButton.tsx b/app/views/RoomView/components/RightButtons/HeaderCallButton.tsx similarity index 88% rename from app/views/RoomView/components/HeaderCallButton.tsx rename to app/views/RoomView/components/RightButtons/HeaderCallButton.tsx index 46d180683fe..dc6efd7deb8 100644 --- a/app/views/RoomView/components/HeaderCallButton.tsx +++ b/app/views/RoomView/components/RightButtons/HeaderCallButton.tsx @@ -1,8 +1,8 @@ import { type ReactElement, useEffect, useRef } from 'react'; -import * as HeaderButton from '../../../containers/Header/components/HeaderButton'; -import { useVideoConf } from '../../../lib/hooks/useVideoConf'; -import { useNewMediaCall } from '../../../lib/hooks/useNewMediaCall'; +import * as HeaderButton from '../../../../containers/Header/components/HeaderButton'; +import { useVideoConf } from '../../../../lib/hooks/useVideoConf'; +import { useNewMediaCall } from '../../../../lib/hooks/useNewMediaCall'; const DOUBLE_TAP_WINDOW_MS = 300; diff --git a/app/views/RoomView/components/RightButtons/OmnichannelRightButtons.tsx b/app/views/RoomView/components/RightButtons/OmnichannelRightButtons.tsx new file mode 100644 index 00000000000..8020e2b7991 --- /dev/null +++ b/app/views/RoomView/components/RightButtons/OmnichannelRightButtons.tsx @@ -0,0 +1,98 @@ +import { type ReactElement } from 'react'; +import { useStore } from 'zustand'; +import { useNavigation } from '@react-navigation/native'; + +import { type TActionSheetOptionsItem, useActionSheet } from '../../../../containers/ActionSheet'; +import * as HeaderButton from '../../../../containers/Header/components/HeaderButton'; +import i18n from '../../../../i18n'; +import { showConfirmationAlert, showErrorAlert } from '../../../../lib/methods/helpers'; +import { events, logEvent } from '../../../../lib/methods/helpers/log'; +import { useCanReturnQueue } from '../../../../ee/omnichannel/hooks/useCanReturnQueue'; +import { useMasterDetail } from '../../../../lib/hooks/useMasterDetail'; +import { useSetting } from '../../../../lib/hooks/useSetting'; +import { returnLivechat } from '../../../../lib/services/restApi'; +import { type RoomStore } from '../../definitions'; +import { useCanPlaceLivechatOnHold } from '../../hooks/useCanPlaceLivechatOnHold'; +import { navigateToScreen, type TRoomStackNavigation } from '../../services/navigateToScreen'; +import { closeLivechat } from '../../services/closeLivechat'; +import { getRoomHeaderFields } from '../../services/getRoomHeaderFields'; +import { placeLivechatOnHold } from '../../services/placeLivechatOnHold'; + +interface IOmnichannelRightButtonsProps { + rid: string; + roomStore: RoomStore; +} + +export const OmnichannelRightButtons = ({ rid, roomStore }: IOmnichannelRightButtonsProps): ReactElement => { + const navigation = useNavigation(); + const isMasterDetail = useMasterDetail(); + const { showActionSheet } = useActionSheet(); + + const livechatRequestComment = useSetting('Livechat_request_comment_when_closing_conversation') as boolean; + + const room = useStore(roomStore, s => s.room); + const canForwardGuest = useStore(roomStore, s => s.canForwardGuest); + const canReturnQueue = useCanReturnQueue(true); + const canPlaceLivechatOnHold = useCanPlaceLivechatOnHold(roomStore); + + const { departmentId } = getRoomHeaderFields(room); + + const handleReturnLivechat = () => { + showConfirmationAlert({ + message: i18n.t('Would_you_like_to_return_the_inquiry'), + confirmationText: i18n.t('Yes'), + onPress: async () => { + try { + await returnLivechat(rid, departmentId); + } catch (e: any) { + showErrorAlert(e.reason, i18n.t('Oops')); + } + } + }); + }; + + const showMoreActions = () => { + logEvent(events.ROOM_SHOW_MORE_ACTIONS); + const options = [] as TActionSheetOptionsItem[]; + if (canPlaceLivechatOnHold) { + options.push({ + title: i18n.t('Place_chat_on_hold'), + icon: 'pause', + onPress: () => placeLivechatOnHold({ rid, navigation }) + }); + } + + if (canForwardGuest) { + options.push({ + title: i18n.t('Forward_Chat'), + icon: 'chat-forward', + onPress: () => { + navigateToScreen({ navigation, isMasterDetail, screen: 'ForwardLivechatView', params: { rid } }); + } + }); + } + + if (canReturnQueue) { + options.push({ + title: i18n.t('Return_to_waiting_line'), + icon: 'move-to-the-queue', + onPress: () => handleReturnLivechat() + }); + } + + options.push({ + title: i18n.t('Close'), + icon: 'chat-close', + onPress: () => closeLivechat({ rid, departmentId, isMasterDetail, livechatRequestComment, navigation }), + danger: true + }); + + showActionSheet({ options }); + }; + + return ( + + + + ); +}; diff --git a/app/views/RoomView/components/RightButtons/RightButtons.tsx b/app/views/RoomView/components/RightButtons/RightButtons.tsx new file mode 100644 index 00000000000..85637c76835 --- /dev/null +++ b/app/views/RoomView/components/RightButtons/RightButtons.tsx @@ -0,0 +1,40 @@ +import { type ReactElement } from 'react'; + +import { type RoomStore } from '../../definitions'; +import { useRoomWithUpdateFromStore } from '../../stores/RoomStoreContext'; +import { OmnichannelRightButtons } from './OmnichannelRightButtons'; +import { RoomRightButtons } from './RoomRightButtons'; +import { ThreadRightButtons } from './ThreadRightButtons'; + +interface IRightButtonsProps { + rid?: string; + tmid?: string; + roomStore: RoomStore; +} + +const RightButtons = ({ rid, tmid, roomStore }: IRightButtonsProps): ReactElement | null => { + const room = useRoomWithUpdateFromStore(roomStore); + + if (!rid) { + return null; + } + + if (room.status === 'INVITED') { + return null; + } + + if (room.t === 'l') { + if (room.status === 'queued') { + return null; + } + return ; + } + + if (tmid) { + return ; + } + + return ; +}; + +export default RightButtons; diff --git a/app/views/RoomView/components/RightButtons/RoomRightButtons.tsx b/app/views/RoomView/components/RightButtons/RoomRightButtons.tsx new file mode 100644 index 00000000000..02d4f88d6a0 --- /dev/null +++ b/app/views/RoomView/components/RightButtons/RoomRightButtons.tsx @@ -0,0 +1,143 @@ +import { type ReactElement } from 'react'; +import { useStore } from 'zustand'; +import { useNavigation } from '@react-navigation/native'; + +import * as HeaderButton from '../../../../containers/Header/components/HeaderButton'; +import { type ISubscription, type SubscriptionType, type TUserStatus } from '../../../../definitions'; +import i18n from '../../../../i18n'; +import { getRoomTitle, isGroupChat } from '../../../../lib/methods/helpers'; +import { events, logEvent } from '../../../../lib/methods/helpers/log'; +import getRoomAccessibilityLabel from '../../../../lib/helpers/getRoomAccessibilityLabel'; +import { useAppSelector } from '../../../../lib/hooks/useAppSelector'; +import { useMasterDetail } from '../../../../lib/hooks/useMasterDetail'; +import { usePermissions } from '../../../../lib/hooks/usePermissions'; +import { useSetting } from '../../../../lib/hooks/useSetting'; +import { getUserSelector } from '../../../../selectors/login'; +import { useTheme } from '../../../../theme'; +import { type RoomStore } from '../../definitions'; +import { useE2EEStatus } from '../../hooks/useE2EEStatus'; +import { useSubscriptionUnreads } from '../../hooks/useSubscriptionUnreads'; +import { navigateToScreen, type TRoomStackNavigation } from '../../services/navigateToScreen'; +import { getRoomHeaderFields } from '../../services/getRoomHeaderFields'; +import { HeaderCallButton } from './HeaderCallButton'; + +interface IRoomRightButtonsProps { + rid: string; + roomStore: RoomStore; +} + +export const RoomRightButtons = ({ rid, roomStore }: IRoomRightButtonsProps): ReactElement => { + const navigation = useNavigation(); + const isMasterDetail = useMasterDetail(); + const { colors } = useTheme(); + + const userId = useAppSelector(state => getUserSelector(state).id); + const threadsEnabled = useSetting('Threads_enabled') as boolean; + const issuesWithNotifications = useAppSelector(state => state.troubleshootingNotification.issuesWithNotifications); + + const room = useStore(roomStore, s => s.room); + const { hasE2EEWarning } = useE2EEStatus(roomStore); + const { tunread, tunreadUser, tunreadGroup, isSelfDm, subscription } = useSubscriptionUnreads(roomStore, userId); + const [canToggleEncryption] = usePermissions(['toggle-room-e2e-encryption'], rid); + + const t = room.t as SubscriptionType; + const { status } = room; + const roomName = getRoomTitle(room); + const roomIsGroupChat = isGroupChat(room as ISubscription); + const { teamMain, encrypted } = getRoomHeaderFields(room); + + const goThreadsView = () => { + logEvent(events.ROOM_GO_THREADS); + navigateToScreen({ navigation, isMasterDetail, screen: 'ThreadMessagesView', params: { rid, t } }); + }; + + const navigateToNotificationOrPushTroubleshoot = () => { + if (!subscription) { + return; + } + if (!issuesWithNotifications) { + navigateToScreen({ navigation, isMasterDetail, screen: 'NotificationPrefView', params: { rid, room: subscription } }); + } else { + navigateToScreen({ navigation, isMasterDetail, screen: 'PushTroubleshootView' }); + } + }; + + const goSearchView = () => { + logEvent(events.ROOM_GO_SEARCH); + navigateToScreen({ + navigation, + isMasterDetail, + screen: 'SearchMessagesView', + params: isMasterDetail ? { rid, t, encrypted, showCloseModal: true } : { rid, t, encrypted } + }); + }; + + const goE2EEToggleRoomView = () => { + logEvent(events.ROOM_GO_E2EE); + navigateToScreen({ navigation, isMasterDetail, screen: 'E2EEToggleRoomView', params: { rid } }); + }; + + const threadsAccessibilityLabel = () => { + if (!tunread.length) { + return i18n.t('Threads'); + } + if (tunreadUser?.length) { + return i18n.t('Threads_dm_unread', { unread: tunreadUser?.length }); + } + if (tunreadGroup?.length) { + return i18n.t('Threads_group_unread', { unread: tunreadGroup?.length }); + } + return i18n.t('Threads_unread', { unread: tunread?.length }); + }; + + const accessibilityRoomName = + !roomIsGroupChat && t === 'd' && !!userId + ? roomName + : getRoomAccessibilityLabel({ type: t, userId, isGroupChat: roomIsGroupChat, status: status as TUserStatus, teamMain }); + + return ( + + {hasE2EEWarning ? ( + + ) : null} + {issuesWithNotifications || (room as ISubscription).disableNotifications ? ( + + ) : null} + {!isSelfDm ? ( + + ) : null} + {threadsEnabled ? ( + } + disabled={hasE2EEWarning} + /> + ) : null} + + + ); +}; diff --git a/app/views/RoomView/components/RightButtons/ThreadRightButtons.tsx b/app/views/RoomView/components/RightButtons/ThreadRightButtons.tsx new file mode 100644 index 00000000000..da9dd99fa12 --- /dev/null +++ b/app/views/RoomView/components/RightButtons/ThreadRightButtons.tsx @@ -0,0 +1,34 @@ +import { type ReactElement } from 'react'; + +import * as HeaderButton from '../../../../containers/Header/components/HeaderButton'; +import i18n from '../../../../i18n'; +import { events, logEvent } from '../../../../lib/methods/helpers/log'; +import { useAppSelector } from '../../../../lib/hooks/useAppSelector'; +import { toggleFollowThread } from '../../../../lib/methods/toggleFollowThread'; +import { getUserSelector } from '../../../../selectors/login'; +import { useThreadFollowing } from '../../hooks/useThreadFollowing'; + +interface IThreadRightButtonsProps { + tmid: string; +} + +export const ThreadRightButtons = ({ tmid }: IThreadRightButtonsProps): ReactElement => { + const userId = useAppSelector(state => getUserSelector(state).id); + const isFollowingThread = useThreadFollowing(tmid, userId); + + const onToggleFollowThread = () => { + logEvent(events.ROOM_TOGGLE_FOLLOW_THREADS); + toggleFollowThread(tmid, isFollowingThread); + }; + + return ( + + + + ); +}; diff --git a/app/views/RoomView/components/RightButtons/__tests__/HeaderCallButton.test.tsx b/app/views/RoomView/components/RightButtons/__tests__/HeaderCallButton.test.tsx new file mode 100644 index 00000000000..30d93b28ee5 --- /dev/null +++ b/app/views/RoomView/components/RightButtons/__tests__/HeaderCallButton.test.tsx @@ -0,0 +1,135 @@ +import { act, fireEvent, render, screen } from '@testing-library/react-native'; + +import { HeaderCallButton } from '../HeaderCallButton'; + +const mockVideoConf = { + showInitCallActionSheet: jest.fn(), + callEnabled: false, + disabledTooltip: false +}; +jest.mock('../../../../../lib/hooks/useVideoConf', () => ({ + useVideoConf: () => mockVideoConf +})); + +const mockMediaCall = { + openNewMediaCall: jest.fn(), + startCallImmediate: jest.fn(), + hasMediaCallPermission: false, + isInActiveCall: false +}; +jest.mock('../../../../../lib/hooks/useNewMediaCall', () => ({ + useNewMediaCall: () => mockMediaCall +})); + +jest.mock('../../../../../containers/Header/components/HeaderButton', () => { + const ReactActual = jest.requireActual('react'); + return { + Item: ({ + accessibilityLabel, + disabled, + iconName, + onPress, + testID + }: { + accessibilityLabel: string; + disabled: boolean; + iconName: string; + onPress: () => void; + testID: string; + }) => ReactActual.createElement('Item', { accessibilityLabel, disabled, iconName, onPress, testID }) + }; +}); + +const renderCallButton = (disabled = false) => + render(); + +describe('HeaderCallButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockVideoConf.callEnabled = false; + mockVideoConf.disabledTooltip = false; + mockMediaCall.hasMediaCallPermission = false; + mockMediaCall.isInActiveCall = false; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders nothing without media call permission and with calls disabled', () => { + renderCallButton(); + + expect(screen.queryByTestId('room-view-header-call')).not.toBeOnTheScreen(); + }); + + it('renders the media call button with media call permission', () => { + mockMediaCall.hasMediaCallPermission = true; + + renderCallButton(); + + const callButton = screen.getByTestId('room-view-header-call'); + expect(callButton).toHaveProp('iconName', 'phone'); + expect(callButton).toHaveProp('accessibilityLabel', 'Call Room Title'); + expect(callButton).toHaveProp('disabled', false); + }); + + it('disables the media call button during an active call', () => { + mockMediaCall.hasMediaCallPermission = true; + mockMediaCall.isInActiveCall = true; + + renderCallButton(); + + expect(screen.getByTestId('room-view-header-call')).toHaveProp('disabled', true); + }); + + it('opens the media call sheet after the double tap window on a single tap', () => { + mockMediaCall.hasMediaCallPermission = true; + + renderCallButton(); + fireEvent.press(screen.getByTestId('room-view-header-call')); + + expect(mockMediaCall.openNewMediaCall).not.toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(300)); + expect(mockMediaCall.openNewMediaCall).toHaveBeenCalled(); + expect(mockMediaCall.startCallImmediate).not.toHaveBeenCalled(); + }); + + it('starts the call immediately on a double tap', () => { + mockMediaCall.hasMediaCallPermission = true; + + renderCallButton(); + fireEvent.press(screen.getByTestId('room-view-header-call')); + fireEvent.press(screen.getByTestId('room-view-header-call')); + + expect(mockMediaCall.startCallImmediate).toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(300)); + expect(mockMediaCall.openNewMediaCall).not.toHaveBeenCalled(); + }); + + it('shows the video conference sheet when calls are enabled without media call permission', () => { + mockVideoConf.callEnabled = true; + + renderCallButton(); + fireEvent.press(screen.getByTestId('room-view-header-call')); + + expect(mockVideoConf.showInitCallActionSheet).toHaveBeenCalled(); + }); + + it('disables the video conference button when the tooltip is disabled', () => { + mockVideoConf.callEnabled = true; + mockVideoConf.disabledTooltip = true; + + renderCallButton(); + + expect(screen.getByTestId('room-view-header-call')).toHaveProp('disabled', true); + }); + + it('disables the video conference button when the header disables it', () => { + mockVideoConf.callEnabled = true; + + renderCallButton(true); + + expect(screen.getByTestId('room-view-header-call')).toHaveProp('disabled', true); + }); +}); diff --git a/app/views/RoomView/components/RightButtons/__tests__/OmnichannelRightButtons.test.tsx b/app/views/RoomView/components/RightButtons/__tests__/OmnichannelRightButtons.test.tsx new file mode 100644 index 00000000000..a2b1aa96254 --- /dev/null +++ b/app/views/RoomView/components/RightButtons/__tests__/OmnichannelRightButtons.test.tsx @@ -0,0 +1,190 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { type TActionSheetOptionsItem } from '../../../../../containers/ActionSheet'; +import { showConfirmationAlert } from '../../../../../lib/methods/helpers'; +import { returnLivechat } from '../../../../../lib/services/restApi'; +import { type RoomStore } from '../../../definitions'; +import { closeLivechat } from '../../../services/closeLivechat'; +import { placeLivechatOnHold } from '../../../services/placeLivechatOnHold'; +import { OmnichannelRightButtons } from '../OmnichannelRightButtons'; + +const mockNavigation = { navigate: jest.fn(), push: jest.fn() }; +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => mockNavigation +})); + +const mockShowActionSheet = jest.fn(); +jest.mock('../../../../../containers/ActionSheet', () => ({ + useActionSheet: () => ({ showActionSheet: mockShowActionSheet }) +})); + +let mockIsMasterDetail = false; +jest.mock('../../../../../lib/hooks/useMasterDetail', () => ({ + useMasterDetail: () => mockIsMasterDetail +})); + +let mockLivechatRequestComment = false; +jest.mock('../../../../../lib/hooks/useSetting', () => ({ + useSetting: () => mockLivechatRequestComment +})); + +let mockRoomState = { + room: { rid: 'rid-1', t: 'l', id: 'rid-1', departmentId: 'department-1' } as Record, + canForwardGuest: false +}; +jest.mock('zustand', () => ({ + useStore: (_store: unknown, selector: (state: typeof mockRoomState) => unknown) => selector(mockRoomState) +})); + +let mockCanReturnQueue = false; +jest.mock('../../../../../ee/omnichannel/hooks/useCanReturnQueue', () => ({ + useCanReturnQueue: () => mockCanReturnQueue +})); + +let mockCanPlaceLivechatOnHold = false; +jest.mock('../../../hooks/useCanPlaceLivechatOnHold', () => ({ + useCanPlaceLivechatOnHold: () => mockCanPlaceLivechatOnHold +})); + +jest.mock('../../../services/closeLivechat', () => ({ closeLivechat: jest.fn() })); +jest.mock('../../../services/placeLivechatOnHold', () => ({ placeLivechatOnHold: jest.fn() })); +jest.mock('../../../../../lib/services/restApi', () => ({ returnLivechat: jest.fn() })); +jest.mock('../../../../../lib/methods/helpers', () => ({ + ...jest.requireActual('../../../../../lib/methods/helpers'), + showConfirmationAlert: jest.fn(), + showErrorAlert: jest.fn() +})); + +jest.mock('../../../../../containers/Header/components/HeaderButton', () => { + const ReactActual = jest.requireActual('react'); + return { + Container: ({ children }: { children: unknown }) => ReactActual.createElement('Container', null, children), + Item: ({ iconName, onPress, testID }: { iconName: string; onPress: () => void; testID: string }) => + ReactActual.createElement('Item', { iconName, onPress, testID }) + }; +}); + +const roomStore = {} as RoomStore; + +const openKebab = (): TActionSheetOptionsItem[] => { + render(); + fireEvent.press(screen.getByTestId('room-view-header-omnichannel-kebab')); + return mockShowActionSheet.mock.calls[0][0].options as TActionSheetOptionsItem[]; +}; + +const titlesOf = (options: TActionSheetOptionsItem[]) => options.map(option => option.title); + +describe('OmnichannelRightButtons', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsMasterDetail = false; + mockLivechatRequestComment = false; + mockRoomState = { + room: { rid: 'rid-1', t: 'l', id: 'rid-1', departmentId: 'department-1' }, + canForwardGuest: false + }; + mockCanReturnQueue = false; + mockCanPlaceLivechatOnHold = false; + }); + + it('renders the kebab button', () => { + render(); + + expect(screen.getByTestId('room-view-header-omnichannel-kebab')).toHaveProp('iconName', 'kebab'); + }); + + it('offers only the close option when no capability is granted', () => { + const options = openKebab(); + + expect(titlesOf(options)).toEqual(['Close']); + expect(options[0].icon).toBe('chat-close'); + expect(options[0].danger).toBe(true); + }); + + it('offers the on-hold option when the chat can be placed on hold', () => { + mockCanPlaceLivechatOnHold = true; + + const options = openKebab(); + + expect(titlesOf(options)).toEqual(['Place chat on hold', 'Close']); + expect(options[0].icon).toBe('pause'); + }); + + it('offers the forward option when the agent can forward the guest', () => { + mockRoomState = { ...mockRoomState, canForwardGuest: true }; + + const options = openKebab(); + + expect(titlesOf(options)).toEqual(['Forward chat', 'Close']); + expect(options[0].icon).toBe('chat-forward'); + }); + + it('offers the return-to-queue option when the agent can return to the queue', () => { + mockCanReturnQueue = true; + + const options = openKebab(); + + expect(titlesOf(options)).toEqual(['Return to waiting line', 'Close']); + expect(options[0].icon).toBe('move-to-the-queue'); + }); + + it('offers every option in order when all capabilities are granted', () => { + mockCanPlaceLivechatOnHold = true; + mockCanReturnQueue = true; + mockRoomState = { ...mockRoomState, canForwardGuest: true }; + + expect(titlesOf(openKebab())).toEqual(['Place chat on hold', 'Forward chat', 'Return to waiting line', 'Close']); + }); + + it('places the chat on hold through the service', () => { + mockCanPlaceLivechatOnHold = true; + + openKebab()[0].onPress?.(); + + expect(placeLivechatOnHold).toHaveBeenCalledWith({ rid: 'rid-1', navigation: mockNavigation }); + }); + + it('navigates to the forward screen on stack mode', () => { + mockRoomState = { ...mockRoomState, canForwardGuest: true }; + + openKebab()[0].onPress?.(); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('ForwardLivechatView', { rid: 'rid-1' }); + }); + + it('navigates to the forward screen through the modal stack on master-detail mode', () => { + mockRoomState = { ...mockRoomState, canForwardGuest: true }; + mockIsMasterDetail = true; + + openKebab()[0].onPress?.(); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('ModalStackNavigator', { + screen: 'ForwardLivechatView', + params: { rid: 'rid-1' } + }); + }); + + it('returns the inquiry only after the confirmation is accepted', async () => { + mockCanReturnQueue = true; + + openKebab()[0].onPress?.(); + + expect(returnLivechat).not.toHaveBeenCalled(); + await (showConfirmationAlert as jest.Mock).mock.calls[0][0].onPress(); + expect(returnLivechat).toHaveBeenCalledWith('rid-1', 'department-1'); + }); + + it('closes the chat with the room department and the request comment setting', () => { + mockLivechatRequestComment = true; + + openKebab()[0].onPress?.(); + + expect(closeLivechat).toHaveBeenCalledWith({ + rid: 'rid-1', + departmentId: 'department-1', + isMasterDetail: false, + livechatRequestComment: true, + navigation: mockNavigation + }); + }); +}); diff --git a/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx b/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx new file mode 100644 index 00000000000..d00a5d4996f --- /dev/null +++ b/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx @@ -0,0 +1,180 @@ +import { act, render, screen } from '@testing-library/react-native'; +import { createStore } from 'zustand'; + +import { type RoomStore } from '../../../definitions'; +import RightButtons from '../RightButtons'; + +jest.mock('../OmnichannelRightButtons', () => { + const ReactActual = jest.requireActual('react'); + const mounts = { count: 0 }; + return { + mounts, + OmnichannelRightButtons: ({ rid }: { rid: string }) => { + ReactActual.useEffect(() => { + mounts.count += 1; + }, []); + return ReactActual.createElement('OmnichannelRightButtons', { rid, testID: 'omnichannel-right-buttons-stub' }); + } + }; +}); + +jest.mock('../ThreadRightButtons', () => { + const ReactActual = jest.requireActual('react'); + const mounts = { count: 0 }; + return { + mounts, + ThreadRightButtons: ({ tmid }: { tmid: string }) => { + ReactActual.useEffect(() => { + mounts.count += 1; + }, []); + return ReactActual.createElement('ThreadRightButtons', { tmid, testID: 'thread-right-buttons-stub' }); + } + }; +}); + +jest.mock('../RoomRightButtons', () => { + const ReactActual = jest.requireActual('react'); + const mounts = { count: 0 }; + return { + mounts, + RoomRightButtons: ({ rid }: { rid: string }) => { + ReactActual.useEffect(() => { + mounts.count += 1; + }, []); + return ReactActual.createElement('RoomRightButtons', { rid, testID: 'room-right-buttons-stub' }); + } + }; +}); + +const omnichannelMock = jest.requireMock('../OmnichannelRightButtons') as { mounts: { count: number } }; +const threadMock = jest.requireMock('../ThreadRightButtons') as { mounts: { count: number } }; +const roomMock = jest.requireMock('../RoomRightButtons') as { mounts: { count: number } }; + +const stubIDs = ['omnichannel-right-buttons-stub', 'thread-right-buttons-stub', 'room-right-buttons-stub']; + +const expectOnlyStub = (present?: string) => { + stubIDs.forEach(id => { + if (id === present) { + expect(screen.getByTestId(id)).toBeOnTheScreen(); + } else { + expect(screen.queryByTestId(id)).not.toBeOnTheScreen(); + } + }); +}; + +const createRoomStore = (room: Record) => { + const store = createStore(() => ({ room })); + return store as typeof store & RoomStore; +}; + +describe('RightButtons routing', () => { + beforeEach(() => { + omnichannelMock.mounts.count = 0; + threadMock.mounts.count = 0; + roomMock.mounts.count = 0; + }); + + it('renders nothing without a rid', () => { + render(); + + expectOnlyStub(); + }); + + it('renders nothing for an invited room', () => { + render(); + + expectOnlyStub(); + }); + + it('renders nothing for a queued omnichannel room', () => { + render(); + + expectOnlyStub(); + }); + + it('renders nothing for an invited omnichannel room before the queued check', () => { + render(); + + expectOnlyStub(); + }); + + it('renders the omnichannel buttons for an active omnichannel room even with a tmid', () => { + render(); + + expectOnlyStub('omnichannel-right-buttons-stub'); + expect(screen.getByTestId('omnichannel-right-buttons-stub')).toHaveProp('rid', 'rid-1'); + }); + + it('renders the thread buttons when a tmid is given', () => { + render(); + + expectOnlyStub('thread-right-buttons-stub'); + expect(screen.getByTestId('thread-right-buttons-stub')).toHaveProp('tmid', 'tmid-1'); + }); + + it('renders the room buttons for a regular room', () => { + render(); + + expectOnlyStub('room-right-buttons-stub'); + expect(screen.getByTestId('room-right-buttons-stub')).toHaveProp('rid', 'rid-1'); + }); + + it('swaps the room buttons for the omnichannel buttons when the room type changes in place', () => { + const roomStore = createRoomStore({ rid: 'rid-1', t: 'c' }); + render(); + + expectOnlyStub('room-right-buttons-stub'); + expect(roomMock.mounts.count).toBe(1); + + act(() => roomStore.setState({ room: { rid: 'rid-1', t: 'l' } })); + + expectOnlyStub('omnichannel-right-buttons-stub'); + expect(omnichannelMock.mounts.count).toBe(1); + expect(roomMock.mounts.count).toBe(1); + }); + + it('remounts into the thread buttons when a tmid appears', () => { + const roomStore = createRoomStore({ rid: 'rid-1', t: 'c' }); + render(); + + expectOnlyStub('room-right-buttons-stub'); + expect(roomMock.mounts.count).toBe(1); + + screen.rerender(); + + expectOnlyStub('thread-right-buttons-stub'); + expect(threadMock.mounts.count).toBe(1); + expect(roomMock.mounts.count).toBe(1); + }); + + it.each([ + ['c', undefined, 'c', 'INVITED', undefined], + ['c', 'INVITED', 'c', undefined, 'room-right-buttons-stub'], + ['l', undefined, 'l', 'queued', undefined], + ['l', 'queued', 'l', undefined, 'omnichannel-right-buttons-stub'], + ['c', undefined, 'l', undefined, 'omnichannel-right-buttons-stub'] + ])('updates buttons when the same Room changes from %s/%s to %s/%s', (t, status, nextType, nextStatus, expected) => { + const room = { rid: 'rid-1', t, status }; + const roomStore = createRoomStore(room); + render(); + + act(() => { + Object.assign(room, { t: nextType, status: nextStatus }); + roomStore.setState({ roomUpdate: { t: nextType, status: nextStatus } } as Partial>); + }); + + expectOnlyStub(expected); + }); + + it('remounts the room buttons after the tmid is cleared', () => { + const roomStore = createRoomStore({ rid: 'rid-1', t: 'c' }); + render(); + + screen.rerender(); + screen.rerender(); + + expectOnlyStub('room-right-buttons-stub'); + expect(roomMock.mounts.count).toBe(2); + expect(threadMock.mounts.count).toBe(1); + }); +}); diff --git a/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx b/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx new file mode 100644 index 00000000000..29a4d923a86 --- /dev/null +++ b/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx @@ -0,0 +1,309 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { events, logEvent } from '../../../../../lib/methods/helpers/log'; +import { type RoomStore } from '../../../definitions'; +import { RoomRightButtons } from '../RoomRightButtons'; + +const mockNavigation = { navigate: jest.fn(), push: jest.fn() }; +jest.mock('../../../../../lib/methods/helpers/log', () => ({ + ...jest.requireActual('../../../../../lib/methods/helpers/log'), + logEvent: jest.fn() +})); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => mockNavigation +})); + +let mockIsMasterDetail = false; +jest.mock('../../../../../lib/hooks/useMasterDetail', () => ({ + useMasterDetail: () => mockIsMasterDetail +})); + +jest.mock('../../../../../theme', () => ({ useTheme: () => ({ colors: { fontDanger: '#f00' } }) })); +jest.mock('../../../../../lib/helpers/getRoomAccessibilityLabel', () => ({ __esModule: true, default: () => 'channel label' })); +jest.mock('../../../../../lib/methods/helpers', () => ({ + ...jest.requireActual('../../../../../lib/methods/helpers'), + getRoomTitle: () => 'Room Title', + isGroupChat: () => false +})); + +let mockThreadsEnabled = true; +jest.mock('../../../../../lib/hooks/useSetting', () => ({ + useSetting: () => mockThreadsEnabled +})); + +let mockAppState = { + login: { user: { id: 'u1', username: 'user', token: 'tok' } }, + troubleshootingNotification: { issuesWithNotifications: false } +}; +jest.mock('../../../../../lib/hooks/useAppSelector', () => ({ + useAppSelector: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState) +})); + +let mockRoomState = { room: { rid: 'rid-1', t: 'c', name: 'general' } as Record }; +jest.mock('zustand', () => ({ + useStore: (_store: unknown, selector: (state: typeof mockRoomState) => unknown) => selector(mockRoomState) +})); + +let mockHasE2EEWarning = false; +jest.mock('../../../hooks/useE2EEStatus', () => ({ + useE2EEStatus: () => ({ + showMissingE2EEKey: mockHasE2EEWarning, + showE2EEDisabledRoom: false, + hasE2EEWarning: mockHasE2EEWarning + }) +})); + +let mockUnreads = { + tunread: [] as string[], + tunreadUser: [] as string[], + tunreadGroup: [] as string[], + isSelfDm: false, + subscription: undefined as unknown +}; +jest.mock('../../../hooks/useSubscriptionUnreads', () => ({ + useSubscriptionUnreads: () => mockUnreads +})); + +let mockCanToggleEncryption = false; +jest.mock('../../../../../lib/hooks/usePermissions', () => ({ + usePermissions: () => [mockCanToggleEncryption] +})); + +jest.mock('../../../../../containers/Header/components/HeaderButton', () => { + const ReactActual = jest.requireActual('react'); + return { + Container: ({ children }: { children: unknown }) => ReactActual.createElement('Container', null, children), + Item: ({ + accessibilityLabel, + color, + disabled, + iconName, + onPress, + testID + }: { + accessibilityLabel?: string; + color?: string; + disabled?: boolean; + iconName: string; + onPress: () => void; + testID: string; + }) => + ReactActual.createElement('Item', { + accessibilityLabel, + color, + disabled, + iconName, + onPress, + testID + }), + BadgeUnread: () => null + }; +}); +jest.mock('../HeaderCallButton', () => { + const ReactActual = jest.requireActual('react'); + return { + HeaderCallButton: ({ rid, disabled, accessibilityLabel }: { rid: string; disabled: boolean; accessibilityLabel: string }) => + ReactActual.createElement('CallButton', { rid, disabled, accessibilityLabel, testID: 'header-call-button-stub' }) + }; +}); + +const roomStore = {} as RoomStore; + +const renderRoomRightButtons = () => render(); + +describe('RoomRightButtons', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsMasterDetail = false; + mockThreadsEnabled = true; + mockAppState = { + login: { user: { id: 'u1', username: 'user', token: 'tok' } }, + troubleshootingNotification: { issuesWithNotifications: false } + }; + mockRoomState = { room: { rid: 'rid-1', t: 'c', name: 'general' } }; + mockHasE2EEWarning = false; + mockUnreads = { tunread: [], tunreadUser: [], tunreadGroup: [], isSelfDm: false, subscription: undefined }; + mockCanToggleEncryption = false; + }); + + it('renders the call, threads and search buttons for a regular channel', () => { + renderRoomRightButtons(); + + expect(screen.getByTestId('room-view-header-threads')).toHaveProp('iconName', 'threads'); + expect(screen.getByTestId('room-view-search')).toHaveProp('accessibilityLabel', 'Search messages'); + expect(screen.queryByTestId('room-view-header-encryption')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('room-view-push-troubleshoot')).not.toBeOnTheScreen(); + }); + + it('labels the call button with the room accessibility name', () => { + renderRoomRightButtons(); + + expect(screen.getByTestId('header-call-button-stub')).toHaveProp('accessibilityLabel', 'Call channel label'); + }); + + it('hides the call button on a self DM', () => { + mockRoomState = { room: { rid: 'rid-1', t: 'd', name: 'user' } }; + mockUnreads = { ...mockUnreads, isSelfDm: true }; + + renderRoomRightButtons(); + + expect(screen.queryByTestId('header-call-button-stub')).not.toBeOnTheScreen(); + expect(screen.getByTestId('room-view-search')).toBeOnTheScreen(); + }); + + it('enables the encryption button and disables the others on an e2ee warning with permission', () => { + mockHasE2EEWarning = true; + mockCanToggleEncryption = true; + mockAppState = { ...mockAppState, troubleshootingNotification: { issuesWithNotifications: true } }; + + renderRoomRightButtons(); + + expect(screen.getByTestId('room-view-header-encryption')).toHaveProp('disabled', false); + expect(screen.getByTestId('room-view-header-threads')).toHaveProp('disabled', true); + expect(screen.getByTestId('room-view-search')).toHaveProp('disabled', true); + expect(screen.getByTestId('header-call-button-stub')).toHaveProp('disabled', true); + expect(screen.getByTestId('room-view-push-troubleshoot')).toHaveProp('disabled', true); + }); + + it('disables the encryption button on an e2ee warning without permission', () => { + mockHasE2EEWarning = true; + + renderRoomRightButtons(); + + expect(screen.getByTestId('room-view-header-encryption')).toHaveProp('disabled', true); + }); + + it.each([false, true])('routes notification issues to push troubleshooting (master-detail: %s)', isMasterDetail => { + mockIsMasterDetail = isMasterDetail; + mockAppState = { ...mockAppState, troubleshootingNotification: { issuesWithNotifications: true } }; + mockUnreads = { ...mockUnreads, subscription: { id: 'rid-1' } }; + + renderRoomRightButtons(); + + expect(screen.getByTestId('room-view-push-troubleshoot')).toHaveProp('color', '#f00'); + fireEvent.press(screen.getByTestId('room-view-push-troubleshoot')); + expect(mockNavigation.navigate).toHaveBeenCalledWith( + ...(isMasterDetail + ? ['ModalStackNavigator', { screen: 'PushTroubleshootView', params: undefined }] + : ['PushTroubleshootView', undefined]) + ); + }); + + it.each([false, true])('routes disabled Room notifications to preferences (master-detail: %s)', isMasterDetail => { + mockIsMasterDetail = isMasterDetail; + mockRoomState = { room: { rid: 'rid-1', t: 'c', name: 'general', disableNotifications: true } }; + mockUnreads = { ...mockUnreads, subscription: { id: 'rid-1' } }; + + renderRoomRightButtons(); + + expect(screen.getByTestId('room-view-push-troubleshoot')).toHaveProp('color', ''); + fireEvent.press(screen.getByTestId('room-view-push-troubleshoot')); + const params = { rid: 'rid-1', room: { id: 'rid-1' } }; + expect(mockNavigation.navigate).toHaveBeenCalledWith( + ...(isMasterDetail ? ['ModalStackNavigator', { screen: 'NotificationPrefView', params }] : ['NotificationPrefView', params]) + ); + }); + + it('does not navigate from the notification button without a subscription', () => { + mockRoomState = { room: { rid: 'rid-1', t: 'c', name: 'general', disableNotifications: true } }; + + renderRoomRightButtons(); + + fireEvent.press(screen.getByTestId('room-view-push-troubleshoot')); + + expect(mockNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('hides the threads button when threads are disabled', () => { + mockThreadsEnabled = false; + + renderRoomRightButtons(); + + expect(screen.queryByTestId('room-view-header-threads')).not.toBeOnTheScreen(); + expect(screen.getByTestId('room-view-search')).toBeOnTheScreen(); + }); + + it('labels the threads button without unreads', () => { + renderRoomRightButtons(); + + expect(screen.getByTestId('room-view-header-threads')).toHaveProp('accessibilityLabel', 'Threads'); + }); + + it('labels the threads button with the direct mention unread count', () => { + mockUnreads = { ...mockUnreads, tunread: ['tm-1'], tunreadUser: ['tm-1'] }; + + renderRoomRightButtons(); + + expect(screen.getByTestId('room-view-header-threads')).toHaveProp('accessibilityLabel', 'Threads, 1 unread, direct mention'); + }); + + it('labels the threads button with the group mention unread count', () => { + mockUnreads = { ...mockUnreads, tunread: ['tm-1'], tunreadGroup: ['tm-1'] }; + + renderRoomRightButtons(); + + expect(screen.getByTestId('room-view-header-threads')).toHaveProp('accessibilityLabel', 'Threads, 1 unread, group mention'); + }); + + it('labels the threads button with the plain unread count', () => { + mockUnreads = { ...mockUnreads, tunread: ['tm-1', 'tm-2'] }; + + renderRoomRightButtons(); + + expect(screen.getByTestId('room-view-header-threads')).toHaveProp('accessibilityLabel', 'Threads, 2 unread'); + }); + + it('navigates to the threads and search screens on stack mode', () => { + mockRoomState = { room: { rid: 'rid-1', t: 'c', name: 'general', encrypted: true } }; + + renderRoomRightButtons(); + + fireEvent.press(screen.getByTestId('room-view-header-threads')); + expect(mockNavigation.navigate).toHaveBeenCalledWith('ThreadMessagesView', { rid: 'rid-1', t: 'c' }); + + fireEvent.press(screen.getByTestId('room-view-search')); + expect(mockNavigation.navigate).toHaveBeenCalledWith('SearchMessagesView', { rid: 'rid-1', t: 'c', encrypted: true }); + }); + + it('navigates through the modal stack on master-detail mode', () => { + mockIsMasterDetail = true; + mockRoomState = { room: { rid: 'rid-1', t: 'c', name: 'general', encrypted: true } }; + + renderRoomRightButtons(); + + fireEvent.press(screen.getByTestId('room-view-header-threads')); + expect(mockNavigation.navigate).toHaveBeenCalledWith('ModalStackNavigator', { + screen: 'ThreadMessagesView', + params: { rid: 'rid-1', t: 'c' } + }); + + fireEvent.press(screen.getByTestId('room-view-search')); + expect(mockNavigation.navigate).toHaveBeenCalledWith('ModalStackNavigator', { + screen: 'SearchMessagesView', + params: { rid: 'rid-1', t: 'c', encrypted: true, showCloseModal: true } + }); + }); + + it.each([false, true])('offers encryption navigation while other buttons are disabled (master-detail: %s)', isMasterDetail => { + mockIsMasterDetail = isMasterDetail; + mockHasE2EEWarning = true; + mockCanToggleEncryption = true; + mockAppState = { ...mockAppState, troubleshootingNotification: { issuesWithNotifications: true } }; + mockUnreads = { ...mockUnreads, subscription: { id: 'rid-1' } }; + renderRoomRightButtons(); + + expect(screen.getByTestId('room-view-header-threads')).toHaveProp('disabled', true); + expect(screen.getByTestId('room-view-search')).toHaveProp('disabled', true); + expect(screen.getByTestId('room-view-push-troubleshoot')).toHaveProp('disabled', true); + expect(screen.getByTestId('room-view-header-encryption')).toHaveProp('disabled', false); + + fireEvent.press(screen.getByTestId('room-view-header-encryption')); + expect(logEvent).toHaveBeenCalledTimes(1); + expect(logEvent).toHaveBeenCalledWith(events.ROOM_GO_E2EE); + expect(mockNavigation.navigate).toHaveBeenCalledWith( + ...(isMasterDetail + ? ['ModalStackNavigator', { screen: 'E2EEToggleRoomView', params: { rid: 'rid-1' } }] + : ['E2EEToggleRoomView', { rid: 'rid-1' }]) + ); + }); +}); diff --git a/app/views/RoomView/components/RightButtons/__tests__/ThreadRightButtons.test.tsx b/app/views/RoomView/components/RightButtons/__tests__/ThreadRightButtons.test.tsx new file mode 100644 index 00000000000..c08922da070 --- /dev/null +++ b/app/views/RoomView/components/RightButtons/__tests__/ThreadRightButtons.test.tsx @@ -0,0 +1,95 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { toggleFollowThread } from '../../../../../lib/methods/toggleFollowThread'; +import { ThreadRightButtons } from '../ThreadRightButtons'; + +let mockAppState = { login: { user: { id: 'u1', username: 'user', token: 'tok' } } }; +jest.mock('../../../../../lib/hooks/useAppSelector', () => ({ + useAppSelector: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState) +})); + +let mockFollowersByThread: Record = {}; +jest.mock('../../../hooks/useThreadFollowing', () => ({ + useThreadFollowing: (tmid: string, userId: string) => mockFollowersByThread[tmid]?.includes(userId) ?? false +})); + +jest.mock('../../../../../lib/methods/toggleFollowThread', () => ({ toggleFollowThread: jest.fn() })); + +jest.mock('../../../../../containers/Header/components/HeaderButton', () => { + const ReactActual = jest.requireActual('react'); + return { + Container: ({ children }: { children: unknown }) => ReactActual.createElement('Container', null, children), + Item: ({ + accessibilityLabel, + iconName, + onPress, + testID + }: { + accessibilityLabel: string; + iconName: string; + onPress: () => void; + testID: string; + }) => ReactActual.createElement('Item', { accessibilityLabel, iconName, onPress, testID }) + }; +}); + +describe('ThreadRightButtons', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFollowersByThread = {}; + mockAppState = { login: { user: { id: 'u1', username: 'user', token: 'tok' } } }; + }); + + it('renders the follow button when the thread is not followed', () => { + render(); + + const followButton = screen.getByTestId('room-view-header-follow'); + expect(followButton).toHaveProp('accessibilityLabel', 'Follow thread'); + expect(followButton).toHaveProp('iconName', 'notification-disabled'); + expect(screen.queryByTestId('room-view-header-unfollow')).not.toBeOnTheScreen(); + }); + + it('renders the unfollow button when the thread is followed', () => { + mockFollowersByThread = { 'tmid-1': ['u1'] }; + + render(); + + const unfollowButton = screen.getByTestId('room-view-header-unfollow'); + expect(unfollowButton).toHaveProp('accessibilityLabel', 'Unfollow thread'); + expect(unfollowButton).toHaveProp('iconName', 'notification'); + expect(screen.queryByTestId('room-view-header-follow')).not.toBeOnTheScreen(); + }); + + it('shows the follow state for the displayed Thread and current user', () => { + mockFollowersByThread = { 'tmid-1': ['u1'] }; + render(); + expect(screen.getByTestId('room-view-header-unfollow')).toBeOnTheScreen(); + + screen.rerender(); + expect(screen.getByTestId('room-view-header-follow')).toBeOnTheScreen(); + expect(screen.queryByTestId('room-view-header-unfollow')).not.toBeOnTheScreen(); + + mockAppState = { login: { user: { id: 'u2', username: 'other', token: 'tok' } } }; + screen.rerender(); + expect(screen.getByTestId('room-view-header-follow')).toBeOnTheScreen(); + expect(screen.queryByTestId('room-view-header-unfollow')).not.toBeOnTheScreen(); + }); + + it('follows the thread when it is not followed yet', () => { + render(); + + fireEvent.press(screen.getByTestId('room-view-header-follow')); + + expect(toggleFollowThread).toHaveBeenCalledWith('tmid-1', false); + }); + + it('unfollows the thread when it is followed', () => { + mockFollowersByThread = { 'tmid-1': ['u1'] }; + + render(); + + fireEvent.press(screen.getByTestId('room-view-header-unfollow')); + + expect(toggleFollowThread).toHaveBeenCalledWith('tmid-1', true); + }); +}); diff --git a/app/views/RoomView/components/__tests__/RightButtons.test.tsx b/app/views/RoomView/components/__tests__/RightButtons.test.tsx new file mode 100644 index 00000000000..f2994aaaac1 --- /dev/null +++ b/app/views/RoomView/components/__tests__/RightButtons.test.tsx @@ -0,0 +1,282 @@ +import { render } from '@testing-library/react-native'; + +import RightButtons from '../RightButtons/RightButtons'; + +const mockNavigation = { navigate: jest.fn(), push: jest.fn() }; +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => mockNavigation +})); +jest.mock('../../../../containers/ActionSheet', () => ({ + useActionSheet: () => ({ showActionSheet: jest.fn() }) +})); +jest.mock('../../../../lib/hooks/useMasterDetail', () => ({ + ...jest.requireActual('../../../../lib/hooks/useMasterDetail'), + useMasterDetail: () => false +})); +jest.mock('../../../../theme', () => ({ useTheme: () => ({ colors: { fontDanger: '#f00' } }) })); +jest.mock('../../../../lib/helpers/getRoomAccessibilityLabel', () => ({ __esModule: true, default: () => 'label' })); +jest.mock('../../../../lib/methods/helpers', () => ({ + ...jest.requireActual('../../../../lib/methods/helpers'), + getRoomTitle: () => 'Room Title', + isGroupChat: () => false +})); + +let mockAppState = { + login: { user: { id: 'u1', username: 'user', token: 'tok' } }, + settings: { Threads_enabled: true, Livechat_request_comment_when_closing_conversation: false }, + troubleshootingNotification: { issuesWithNotifications: false }, + permissions: { 'toggle-room-e2e-encryption': ['perm'] } +}; +jest.mock('../../../../lib/hooks/useAppSelector', () => ({ + useAppSelector: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState) +})); + +let mockRoomState = { + room: { rid: 'rid-1', t: 'c', name: 'general' } as any, + canForwardGuest: false +}; +jest.mock('zustand', () => ({ + useStore: (_store: unknown, selector: (state: typeof mockRoomState) => unknown) => selector(mockRoomState) +})); +jest.mock('../../../../ee/omnichannel/hooks/useCanReturnQueue', () => ({ useCanReturnQueue: () => false })); +jest.mock('../../hooks/useCanPlaceLivechatOnHold', () => ({ useCanPlaceLivechatOnHold: () => false })); + +let mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: false, hasE2EEWarning: false }; +jest.mock('../../hooks/useE2EEStatus', () => ({ useE2EEStatus: () => mockE2EEStatus })); + +let mockHeaderHooks = { + isFollowingThread: false, + tunread: [] as string[], + tunreadUser: [] as string[], + tunreadGroup: [] as string[], + isSelfDm: false, + canToggleEncryption: false, + subscription: undefined as any +}; +jest.mock('../../hooks/useThreadFollowing', () => ({ useThreadFollowing: () => mockHeaderHooks.isFollowingThread })); +jest.mock('../../hooks/useSubscriptionUnreads', () => ({ + useSubscriptionUnreads: () => { + const { tunread, tunreadUser, tunreadGroup, isSelfDm, subscription } = mockHeaderHooks; + return { tunread, tunreadUser, tunreadGroup, isSelfDm, subscription }; + } +})); +jest.mock('../../../../lib/hooks/usePermissions', () => ({ + usePermissions: () => [mockHeaderHooks.canToggleEncryption] +})); + +jest.mock('../../../../containers/Header/components/HeaderButton', () => { + const ReactActual = jest.requireActual('react'); + return { + Container: ({ children }: any) => ReactActual.createElement('Container', null, children), + Item: (props: any) => ReactActual.createElement('Item', props), + BadgeUnread: () => null + }; +}); +jest.mock('../RightButtons/HeaderCallButton', () => { + const ReactActual = jest.requireActual('react'); + return { + HeaderCallButton: ({ rid, disabled, accessibilityLabel }: { rid: string; disabled: boolean; accessibilityLabel: string }) => + ReactActual.createElement('CallButton', { rid, disabled, accessibilityLabel, testID: 'header-call-button-stub' }) + }; +}); + +const allTestIDs = [ + 'room-view-search', + 'room-view-header-threads', + 'header-call-button-stub', + 'room-view-header-encryption', + 'room-view-push-troubleshoot', + 'room-view-header-omnichannel-kebab', + 'room-view-header-follow', + 'room-view-header-unfollow' +]; + +describe('RightButtons', () => { + const roomStore = {} as any; + + const expectOnly = (queryByTestId: (id: string) => unknown, present: string[]) => { + present.forEach(id => expect(queryByTestId(id)).toBeTruthy()); + allTestIDs.filter(id => !present.includes(id)).forEach(id => expect(queryByTestId(id)).toBeNull()); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockAppState = { + login: { user: { id: 'u1', username: 'user', token: 'tok' } }, + settings: { Threads_enabled: true, Livechat_request_comment_when_closing_conversation: false }, + troubleshootingNotification: { issuesWithNotifications: false }, + permissions: { 'toggle-room-e2e-encryption': ['perm'] } + }; + mockRoomState = { + room: { rid: 'rid-1', t: 'c', name: 'general' }, + canForwardGuest: false + }; + mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: false, hasE2EEWarning: false }; + mockHeaderHooks = { + isFollowingThread: false, + tunread: [], + tunreadUser: [], + tunreadGroup: [], + isSelfDm: false, + canToggleEncryption: false, + subscription: undefined + }; + }); + + it('renders nothing without a rid', () => { + const { queryByTestId, toJSON } = render(); + expect(toJSON()).toBeNull(); + expectOnly(queryByTestId, []); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders nothing for an invited room', () => { + mockRoomState = { ...mockRoomState, room: { rid: 'rid-1', t: 'c', name: 'general', status: 'INVITED' } }; + const { queryByTestId, toJSON } = render(); + expect(toJSON()).toBeNull(); + expectOnly(queryByTestId, []); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders nothing for a queued omnichannel room', () => { + mockRoomState = { ...mockRoomState, room: { rid: 'rid-1', t: 'l', name: 'chat', status: 'queued' } }; + const { queryByTestId, toJSON } = render(); + expect(toJSON()).toBeNull(); + expectOnly(queryByTestId, []); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders only the kebab for an active omnichannel room', () => { + mockRoomState = { ...mockRoomState, room: { rid: 'rid-1', t: 'l', name: 'chat' } }; + const { queryByTestId, toJSON } = render(); + expectOnly(queryByTestId, ['room-view-header-omnichannel-kebab']); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders the unfollow button for a followed thread', () => { + mockHeaderHooks = { ...mockHeaderHooks, isFollowingThread: true }; + const { queryByTestId, toJSON } = render(); + expectOnly(queryByTestId, ['room-view-header-unfollow']); + expect(queryByTestId('room-view-header-unfollow')).toHaveProp('accessibilityLabel', 'Unfollow thread'); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders the follow button for an unfollowed thread', () => { + const { queryByTestId, toJSON } = render(); + expectOnly(queryByTestId, ['room-view-header-follow']); + expect(queryByTestId('room-view-header-follow')).toHaveProp('accessibilityLabel', 'Follow thread'); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders call, threads and search for a regular channel', () => { + const { queryByTestId, toJSON } = render(); + expectOnly(queryByTestId, ['header-call-button-stub', 'room-view-header-threads', 'room-view-search']); + expect(toJSON()).toMatchSnapshot(); + }); + + it('enables the encryption button when the user can toggle encryption', () => { + mockRoomState = { ...mockRoomState, room: { rid: 'rid-1', t: 'c', name: 'general', encrypted: true } }; + mockE2EEStatus = { showMissingE2EEKey: true, showE2EEDisabledRoom: false, hasE2EEWarning: true }; + mockHeaderHooks = { ...mockHeaderHooks, canToggleEncryption: true }; + const { queryByTestId, toJSON } = render(); + expectOnly(queryByTestId, [ + 'room-view-header-encryption', + 'header-call-button-stub', + 'room-view-header-threads', + 'room-view-search' + ]); + expect(queryByTestId('room-view-header-encryption')).toHaveProp('disabled', false); + expect(queryByTestId('room-view-search')).toHaveProp('disabled', true); + expect(toJSON()).toMatchSnapshot(); + }); + + it('disables the encryption button when the user cannot toggle encryption', () => { + mockRoomState = { ...mockRoomState, room: { rid: 'rid-1', t: 'c', name: 'general', encrypted: true } }; + mockE2EEStatus = { showMissingE2EEKey: true, showE2EEDisabledRoom: false, hasE2EEWarning: true }; + const { queryByTestId, toJSON } = render(); + expectOnly(queryByTestId, [ + 'room-view-header-encryption', + 'header-call-button-stub', + 'room-view-header-threads', + 'room-view-search' + ]); + expect(queryByTestId('room-view-header-encryption')).toHaveProp('disabled', true); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders the encryption button when the room has e2ee disabled', () => { + mockRoomState = { ...mockRoomState, room: { rid: 'rid-1', t: 'c', name: 'general', encrypted: true } }; + mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: true, hasE2EEWarning: true }; + const { queryByTestId, toJSON } = render(); + expectOnly(queryByTestId, [ + 'room-view-header-encryption', + 'header-call-button-stub', + 'room-view-header-threads', + 'room-view-search' + ]); + expect(queryByTestId('room-view-header-encryption')).toHaveProp('disabled', true); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders the push troubleshoot button when there are notification issues', () => { + mockAppState = { ...mockAppState, troubleshootingNotification: { issuesWithNotifications: true } }; + const { queryByTestId, toJSON } = render(); + expectOnly(queryByTestId, [ + 'room-view-push-troubleshoot', + 'header-call-button-stub', + 'room-view-header-threads', + 'room-view-search' + ]); + expect(queryByTestId('room-view-push-troubleshoot')).toHaveProp('color', '#f00'); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders the push troubleshoot button when notifications are disabled for the room', () => { + mockRoomState = { ...mockRoomState, room: { rid: 'rid-1', t: 'c', name: 'general', disableNotifications: true } }; + const { queryByTestId, toJSON } = render(); + expectOnly(queryByTestId, [ + 'room-view-push-troubleshoot', + 'header-call-button-stub', + 'room-view-header-threads', + 'room-view-search' + ]); + expect(queryByTestId('room-view-push-troubleshoot')).toHaveProp('color', ''); + expect(toJSON()).toMatchSnapshot(); + }); + + it('hides the threads button when threads are disabled', () => { + mockAppState = { + ...mockAppState, + settings: { Threads_enabled: false, Livechat_request_comment_when_closing_conversation: false } + }; + const { queryByTestId, toJSON } = render(); + expectOnly(queryByTestId, ['header-call-button-stub', 'room-view-search']); + expect(toJSON()).toMatchSnapshot(); + }); + + it('labels the threads button with the direct mention unread count', () => { + mockHeaderHooks = { ...mockHeaderHooks, tunread: ['tm-1'], tunreadUser: ['tm-1'] }; + const { queryByTestId } = render(); + expect(queryByTestId('room-view-header-threads')).toHaveProp('accessibilityLabel', 'Threads, 1 unread, direct mention'); + }); + + it('labels the threads button with the group mention unread count', () => { + mockHeaderHooks = { ...mockHeaderHooks, tunread: ['tm-1'], tunreadGroup: ['tm-1'] }; + const { queryByTestId } = render(); + expect(queryByTestId('room-view-header-threads')).toHaveProp('accessibilityLabel', 'Threads, 1 unread, group mention'); + }); + + it('labels the threads button with the plain unread count', () => { + mockHeaderHooks = { ...mockHeaderHooks, tunread: ['tm-1', 'tm-2'] }; + const { queryByTestId } = render(); + expect(queryByTestId('room-view-header-threads')).toHaveProp('accessibilityLabel', 'Threads, 2 unread'); + }); + + it('hides the call button on a self DM', () => { + mockRoomState = { ...mockRoomState, room: { rid: 'rid-1', t: 'd', name: 'user' } }; + mockHeaderHooks = { ...mockHeaderHooks, isSelfDm: true }; + const { queryByTestId, toJSON } = render(); + expectOnly(queryByTestId, ['room-view-header-threads', 'room-view-search']); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/views/RoomView/components/__tests__/__snapshots__/RightButtons.test.tsx.snap b/app/views/RoomView/components/__tests__/__snapshots__/RightButtons.test.tsx.snap new file mode 100644 index 00000000000..ed2a459107c --- /dev/null +++ b/app/views/RoomView/components/__tests__/__snapshots__/RightButtons.test.tsx.snap @@ -0,0 +1,265 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RightButtons disables the encryption button when the user cannot toggle encryption 1`] = ` + + + + + + +`; + +exports[`RightButtons enables the encryption button when the user can toggle encryption 1`] = ` + + + + + + +`; + +exports[`RightButtons hides the call button on a self DM 1`] = ` + + + + +`; + +exports[`RightButtons hides the threads button when threads are disabled 1`] = ` + + + + +`; + +exports[`RightButtons renders call, threads and search for a regular channel 1`] = ` + + + + + +`; + +exports[`RightButtons renders nothing for a queued omnichannel room 1`] = `null`; + +exports[`RightButtons renders nothing for an invited room 1`] = `null`; + +exports[`RightButtons renders nothing without a rid 1`] = `null`; + +exports[`RightButtons renders only the kebab for an active omnichannel room 1`] = ` + + + +`; + +exports[`RightButtons renders the encryption button when the room has e2ee disabled 1`] = ` + + + + + + +`; + +exports[`RightButtons renders the follow button for an unfollowed thread 1`] = ` + + + +`; + +exports[`RightButtons renders the push troubleshoot button when notifications are disabled for the room 1`] = ` + + + + + + +`; + +exports[`RightButtons renders the push troubleshoot button when there are notification issues 1`] = ` + + + + + + +`; + +exports[`RightButtons renders the unfollow button for a followed thread 1`] = ` + + + +`; diff --git a/app/views/RoomView/definitions.ts b/app/views/RoomView/definitions.ts index ce959453062..ebe6b696cbb 100644 --- a/app/views/RoomView/definitions.ts +++ b/app/views/RoomView/definitions.ts @@ -97,6 +97,7 @@ export type TComposerExternalState = Omit ({ + useAppSelector: (selector: (state: typeof mockState) => unknown) => selector(mockState) +})); +jest.mock('../../../../lib/store/auxStore', () => ({ store: { getState: () => mockState } })); +jest.mock('@rocket.chat/mobile-crypto', () => ({})); + +const createRoomStore = (room: IRoomViewState['room']) => + createStore(() => ({ room, roomUpdate: {} as IRoomViewState['roomUpdate'] })) as RoomStore; + +describe('useE2EEStatus', () => { + beforeEach(() => { + mockState = { server: { version: '7.0.0' }, settings: { E2E_Enable: true }, encryption: { enabled: true } }; + }); + + it('does not warn for a preview Room without an encrypted field', () => { + const store = createRoomStore({ rid: 'rid-1', t: 'c' }); + + expect(renderHook(() => useE2EEStatus(store)).result.current).toEqual({ + showMissingE2EEKey: false, + showE2EEDisabledRoom: false, + hasE2EEWarning: false + }); + }); + + it.each([ + [true, true, undefined, true, false], + [true, true, 'key', false, false], + [false, true, 'key', false, true], + [true, false, undefined, false, false], + [false, false, undefined, false, false] + ])( + 'derives warnings with session encryption %s, Room encryption %s and key %s', + (enabled, encrypted, E2EKey, missing, disabled) => { + mockState.encryption.enabled = enabled; + const room = { rid: 'rid-1', t: 'c', encrypted, E2EKey }; + const store = createRoomStore(room); + + expect(renderHook(() => useE2EEStatus(store)).result.current).toEqual({ + showMissingE2EEKey: missing, + showE2EEDisabledRoom: disabled, + hasE2EEWarning: missing || disabled + }); + } + ); + + it('clears the missing-key warning when the same Room receives its key', () => { + const room = { rid: 'rid-1', t: 'c', encrypted: true, E2EKey: undefined as string | undefined }; + const store = createRoomStore(room); + const { result } = renderHook(() => useE2EEStatus(store)); + expect(result.current.hasE2EEWarning).toBe(true); + + act(() => { + room.E2EKey = 'key'; + store.setState({ roomUpdate: { E2EKey: 'key' } }); + }); + + expect(result.current).toEqual({ showMissingE2EEKey: false, showE2EEDisabledRoom: false, hasE2EEWarning: false }); + }); +}); diff --git a/app/views/RoomView/hooks/__tests__/useHeader.test.tsx b/app/views/RoomView/hooks/__tests__/useHeader.test.tsx index f64dad3f2a9..24772b1dc09 100644 --- a/app/views/RoomView/hooks/__tests__/useHeader.test.tsx +++ b/app/views/RoomView/hooks/__tests__/useHeader.test.tsx @@ -8,7 +8,7 @@ let mockTestStore: RoomStore; jest.mock('../useGoRoomActionsView', () => ({ useGoRoomActionsView: jest.fn(() => jest.fn()) })); jest.mock('../../components/LeftButtons', () => ({ __esModule: true, default: 'LeftButtons' })); -jest.mock('../../components/RightButtons', () => ({ __esModule: true, default: 'RightButtons' })); +jest.mock('../../components/RightButtons/RightButtons', () => ({ __esModule: true, default: 'RightButtons' })); jest.mock('../../../../containers/RoomHeader', () => ({ __esModule: true, default: 'RoomHeader' })); jest.mock('../../../../lib/methods/helpers', () => ({ getRoomTitle: jest.fn(() => 'Room Title'), diff --git a/app/views/RoomView/hooks/useE2EEStatus.ts b/app/views/RoomView/hooks/useE2EEStatus.ts index d85894822ee..eab1d531863 100644 --- a/app/views/RoomView/hooks/useE2EEStatus.ts +++ b/app/views/RoomView/hooks/useE2EEStatus.ts @@ -9,11 +9,11 @@ export const useE2EEStatus = (roomStore: RoomStore): IUseE2EEStatusResult => { const room = useRoomWithUpdateFromStore(roomStore); if (!('encrypted' in room)) { - return { showMissingE2EEKey: false, showE2EEDisabledRoom: false }; + return { showMissingE2EEKey: false, showE2EEDisabledRoom: false, hasE2EEWarning: false }; } const showMissingE2EEKey = isMissingRoomE2EEKey({ encryptionEnabled, roomEncrypted: room.encrypted, E2EKey: room.E2EKey }); const showE2EEDisabledRoom = isE2EEDisabledEncryptedRoom({ encryptionEnabled, roomEncrypted: room.encrypted }); - return { showMissingE2EEKey, showE2EEDisabledRoom }; + return { showMissingE2EEKey, showE2EEDisabledRoom, hasE2EEWarning: showMissingE2EEKey || showE2EEDisabledRoom }; }; diff --git a/app/views/RoomView/hooks/useHeader.tsx b/app/views/RoomView/hooks/useHeader.tsx index b1ed36c3cf5..98522f5d8dd 100644 --- a/app/views/RoomView/hooks/useHeader.tsx +++ b/app/views/RoomView/hooks/useHeader.tsx @@ -9,7 +9,7 @@ import { getRoomTitle, isGroupChat } from '../../../lib/methods/helpers'; import { isInviteSubscription } from '../../../lib/methods/isInviteSubscription'; import { type IOmnichannelSource, type ISubscription, type IVisitor } from '../../../definitions'; import LeftButtons from '../components/LeftButtons'; -import RightButtons from '../components/RightButtons'; +import RightButtons from '../components/RightButtons/RightButtons'; import { type IRoomViewProps, type IRoomViewState } from '../definitions'; import { type RoomStore } from '../definitions'; import { useGoRoomActionsView } from './useGoRoomActionsView'; diff --git a/app/views/RoomView/services/__tests__/closeLivechat.test.ts b/app/views/RoomView/services/__tests__/closeLivechat.test.ts new file mode 100644 index 00000000000..b6c196f0afb --- /dev/null +++ b/app/views/RoomView/services/__tests__/closeLivechat.test.ts @@ -0,0 +1,127 @@ +import { closeLivechat as closeLivechatService } from '../../../../lib/methods/helpers/closeLivechat'; +import { showErrorAlert } from '../../../../lib/methods/helpers/info'; +import log from '../../../../lib/methods/helpers/log'; +import { getDepartmentInfo, getTagsList } from '../../../../lib/services/restApi'; +import { navigateToScreen, type TRoomStackNavigation } from '../navigateToScreen'; +import { closeLivechat } from '../closeLivechat'; + +jest.mock('../../../../lib/methods/helpers/closeLivechat', () => ({ closeLivechat: jest.fn(() => Promise.resolve()) })); +jest.mock('../../../../lib/services/restApi', () => ({ + getDepartmentInfo: jest.fn(), + getTagsList: jest.fn() +})); +jest.mock('../navigateToScreen', () => ({ navigateToScreen: jest.fn() })); +jest.mock('../../../../lib/methods/helpers/info', () => ({ showErrorAlert: jest.fn() })); +jest.mock('../../../../lib/methods/helpers/log', () => ({ __esModule: true, default: jest.fn() })); + +const mockCloseLivechatService = closeLivechatService as jest.Mock; +const mockGetDepartmentInfo = getDepartmentInfo as jest.Mock; +const mockGetTagsList = getTagsList as jest.Mock; +const mockNavigateToScreen = navigateToScreen as jest.Mock; + +const navigation = { navigate: jest.fn() } as unknown as TRoomStackNavigation; + +describe('closeLivechat', () => { + beforeEach(() => jest.clearAllMocks()); + + it('closes the chat with the default comment when neither a comment nor tags are required', async () => { + await closeLivechat({ rid: 'rid-1', isMasterDetail: false, livechatRequestComment: false, navigation }); + + expect(mockCloseLivechatService).toHaveBeenCalledWith({ + rid: 'rid-1', + isMasterDetail: false, + comment: 'Chat closed by agent' + }); + expect(mockNavigateToScreen).not.toHaveBeenCalled(); + }); + + it('navigates to the close view when a comment is required', async () => { + await closeLivechat({ rid: 'rid-1', isMasterDetail: true, livechatRequestComment: true, navigation }); + + expect(mockCloseLivechatService).not.toHaveBeenCalled(); + expect(mockNavigateToScreen).toHaveBeenCalledWith({ + navigation, + isMasterDetail: true, + screen: 'CloseLivechatView', + params: { rid: 'rid-1', departmentId: undefined, departmentInfo: undefined, tagsList: undefined } + }); + }); + + it('fetches the tags list and navigates when the department requires tags', async () => { + const department = { _id: 'dep-1', requestTagBeforeClosingChat: true }; + mockGetDepartmentInfo.mockResolvedValueOnce({ success: true, department }); + mockGetTagsList.mockResolvedValueOnce([{ _id: 'tag-1' }]); + + await closeLivechat({ + rid: 'rid-1', + departmentId: 'dep-1', + isMasterDetail: false, + livechatRequestComment: false, + navigation + }); + + expect(mockGetDepartmentInfo).toHaveBeenCalledWith('dep-1'); + expect(mockGetTagsList).toHaveBeenCalledTimes(1); + expect(mockCloseLivechatService).not.toHaveBeenCalled(); + expect(mockNavigateToScreen).toHaveBeenCalledWith({ + navigation, + isMasterDetail: false, + screen: 'CloseLivechatView', + params: { rid: 'rid-1', departmentId: 'dep-1', departmentInfo: department, tagsList: [{ _id: 'tag-1' }] } + }); + }); + + it('ignores a failed department lookup and closes with the default comment', async () => { + mockGetDepartmentInfo.mockResolvedValueOnce({ success: false }); + + await closeLivechat({ + rid: 'rid-1', + departmentId: 'dep-1', + isMasterDetail: false, + livechatRequestComment: false, + navigation + }); + + expect(mockCloseLivechatService).toHaveBeenCalledTimes(1); + expect(mockNavigateToScreen).not.toHaveBeenCalled(); + }); + + it.each(['department', 'tags'])('reports failed %s requests without closing or navigating', async request => { + const error = new Error('offline'); + if (request === 'department') { + mockGetDepartmentInfo.mockRejectedValueOnce(error); + } else { + mockGetDepartmentInfo.mockResolvedValueOnce({ success: true, department: { requestTagBeforeClosingChat: true } }); + mockGetTagsList.mockRejectedValueOnce(error); + } + + await expect( + closeLivechat({ rid: 'rid-1', departmentId: 'dep-1', isMasterDetail: false, livechatRequestComment: false, navigation }) + ).resolves.toBeUndefined(); + + expect(showErrorAlert).toHaveBeenCalledWith('offline', 'Oops!'); + expect(log).toHaveBeenCalledWith(error); + expect(mockCloseLivechatService).not.toHaveBeenCalled(); + expect(mockNavigateToScreen).not.toHaveBeenCalled(); + }); + + it.each([ + [{ error: 'error-not-allowed', reason: 'server reason' }, 'Not allowed'], + [{ error: 'untranslated-error', reason: 'server reason' }, 'server reason'] + ])('reports REST errors using translated codes or the server reason', async (error, message) => { + mockGetDepartmentInfo.mockRejectedValueOnce(error); + + await closeLivechat({ + rid: 'rid-1', + departmentId: 'dep-1', + isMasterDetail: false, + livechatRequestComment: false, + navigation + }); + + expect(showErrorAlert).toHaveBeenCalledWith(message, 'Oops!'); + expect(log).toHaveBeenCalledWith(error); + expect(mockCloseLivechatService).not.toHaveBeenCalled(); + expect(mockNavigateToScreen).not.toHaveBeenCalled(); + }); +}); diff --git a/app/views/RoomView/services/__tests__/getRoomHeaderFields.test.ts b/app/views/RoomView/services/__tests__/getRoomHeaderFields.test.ts new file mode 100644 index 00000000000..81626809a5c --- /dev/null +++ b/app/views/RoomView/services/__tests__/getRoomHeaderFields.test.ts @@ -0,0 +1,23 @@ +import { getRoomHeaderFields } from '../getRoomHeaderFields'; + +describe('getRoomHeaderFields', () => { + it('returns safe defaults for a preview Room', () => { + expect(getRoomHeaderFields({ rid: 'rid-1', t: 'c' })).toEqual({ + teamMain: false, + encrypted: undefined, + departmentId: undefined + }); + }); + + it('preserves the header fields of a subscribed Room', () => { + const room = { id: 'subscription-1', rid: 'rid-1', t: 'l', teamMain: true, encrypted: false, departmentId: 'department-1' }; + + expect(getRoomHeaderFields(room)).toEqual({ teamMain: true, encrypted: false, departmentId: 'department-1' }); + }); + + it('keeps preview encryption but does not treat a preview department as a Subscription field', () => { + const room = { rid: 'rid-1', t: 'c', encrypted: true, departmentId: 'department-1' }; + + expect(getRoomHeaderFields(room)).toEqual({ teamMain: false, encrypted: true, departmentId: undefined }); + }); +}); diff --git a/app/views/RoomView/services/__tests__/navigateToScreen.test.ts b/app/views/RoomView/services/__tests__/navigateToScreen.test.ts new file mode 100644 index 00000000000..666ff15aeb4 --- /dev/null +++ b/app/views/RoomView/services/__tests__/navigateToScreen.test.ts @@ -0,0 +1,43 @@ +import { SubscriptionType } from '../../../../definitions'; +import { navigateToScreen, type TRoomStackNavigation, type TRoomStackParamList } from '../navigateToScreen'; + +describe('navigateToScreen', () => { + const navigate = jest.fn(); + const navigation = { navigate } as unknown as TRoomStackNavigation; + const threadMessagesParams: TRoomStackParamList['ThreadMessagesView'] = { rid: 'rid-1', t: SubscriptionType.CHANNEL }; + + beforeEach(() => jest.clearAllMocks()); + + it('navigates straight to the screen on stack mode', () => { + navigateToScreen({ + navigation, + isMasterDetail: false, + screen: 'ThreadMessagesView', + params: threadMessagesParams + }); + + expect(navigate).toHaveBeenCalledWith('ThreadMessagesView', { rid: 'rid-1', t: 'c' }); + }); + + it('navigates through the modal stack on master-detail mode', () => { + navigateToScreen({ navigation, isMasterDetail: true, screen: 'ThreadMessagesView', params: threadMessagesParams }); + + expect(navigate).toHaveBeenCalledWith('ModalStackNavigator', { + screen: 'ThreadMessagesView', + params: { rid: 'rid-1', t: 'c' } + }); + }); + + it('navigates without params', () => { + navigateToScreen({ navigation, isMasterDetail: false, screen: 'PushTroubleshootView' }); + + expect(navigate).toHaveBeenCalledWith('PushTroubleshootView', undefined); + }); + + it('rejects routes with required params when params are missing', () => { + // @ts-expect-error ThreadMessagesView requires rid and t + navigateToScreen({ navigation, isMasterDetail: false, screen: 'ThreadMessagesView' }); + + expect(navigate).toHaveBeenCalledWith('ThreadMessagesView', undefined); + }); +}); diff --git a/app/views/RoomView/services/__tests__/placeLivechatOnHold.test.ts b/app/views/RoomView/services/__tests__/placeLivechatOnHold.test.ts new file mode 100644 index 00000000000..6aa5273d5e2 --- /dev/null +++ b/app/views/RoomView/services/__tests__/placeLivechatOnHold.test.ts @@ -0,0 +1,49 @@ +import { showConfirmationAlert, showErrorAlert } from '../../../../lib/methods/helpers'; +import { onHoldLivechat } from '../../../../lib/services/restApi'; +import { placeLivechatOnHold } from '../placeLivechatOnHold'; +import { type TRoomStackNavigation } from '../navigateToScreen'; + +jest.mock('../../../../lib/methods/helpers', () => ({ + showConfirmationAlert: jest.fn(), + showErrorAlert: jest.fn() +})); +jest.mock('../../../../lib/services/restApi', () => ({ onHoldLivechat: jest.fn(() => Promise.resolve()) })); + +const mockShowConfirmationAlert = showConfirmationAlert as jest.Mock; +const mockShowErrorAlert = showErrorAlert as jest.Mock; +const mockOnHoldLivechat = onHoldLivechat as jest.Mock; + +const confirm = () => mockShowConfirmationAlert.mock.calls[0][0].onPress(); + +describe('placeLivechatOnHold', () => { + const navigate = jest.fn(); + const navigation = { navigate } as unknown as TRoomStackNavigation; + + beforeEach(() => jest.clearAllMocks()); + + it('asks for confirmation before placing the chat on hold', () => { + placeLivechatOnHold({ rid: 'rid-1', navigation }); + + expect(mockShowConfirmationAlert).toHaveBeenCalledTimes(1); + expect(mockOnHoldLivechat).not.toHaveBeenCalled(); + }); + + it('places the chat on hold and returns to the rooms list once confirmed', async () => { + placeLivechatOnHold({ rid: 'rid-1', navigation }); + + await confirm(); + + expect(mockOnHoldLivechat).toHaveBeenCalledWith('rid-1'); + expect(navigate).toHaveBeenCalledWith('RoomsListView'); + }); + + it('shows the server error and stays on the room when the request fails', async () => { + mockOnHoldLivechat.mockRejectedValueOnce({ data: { error: 'error-on-hold' } }); + placeLivechatOnHold({ rid: 'rid-1', navigation }); + + await confirm(); + + expect(mockShowErrorAlert).toHaveBeenCalledWith('error-on-hold', 'Oops!'); + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/app/views/RoomView/services/closeLivechat.ts b/app/views/RoomView/services/closeLivechat.ts new file mode 100644 index 00000000000..7f118ea2eac --- /dev/null +++ b/app/views/RoomView/services/closeLivechat.ts @@ -0,0 +1,52 @@ +import { type ILivechatDepartment } from '../../../definitions/ILivechatDepartment'; +import { type ILivechatTag } from '../../../definitions/ILivechatTag'; +import i18n from '../../../i18n'; +import { closeLivechat as closeLivechatService } from '../../../lib/methods/helpers/closeLivechat'; +import { showErrorAlert } from '../../../lib/methods/helpers/info'; +import log from '../../../lib/methods/helpers/log'; +import { getDepartmentInfo, getTagsList } from '../../../lib/services/restApi'; +import { navigateToScreen, type TRoomStackNavigation } from './navigateToScreen'; + +export const closeLivechat = async ({ + rid, + departmentId, + isMasterDetail, + livechatRequestComment, + navigation +}: { + rid: string; + departmentId?: string; + isMasterDetail: boolean; + livechatRequestComment: boolean; + navigation: TRoomStackNavigation; +}): Promise => { + try { + let departmentInfo: ILivechatDepartment | undefined; + let tagsList: ILivechatTag[] | undefined; + + if (departmentId) { + const result = await getDepartmentInfo(departmentId); + if (result.success) { + departmentInfo = result.department as ILivechatDepartment; + } + } + + if (departmentInfo?.requestTagBeforeClosingChat) { + tagsList = await getTagsList(); + } + + if (!livechatRequestComment && !departmentInfo?.requestTagBeforeClosingChat) { + return closeLivechatService({ rid, isMasterDetail, comment: i18n.t('Chat_closed_by_agent') }); + } + + navigateToScreen({ + navigation, + isMasterDetail, + screen: 'CloseLivechatView', + params: { rid, departmentId, departmentInfo, tagsList } + }); + } catch (e: any) { + showErrorAlert(i18n.isTranslated(e.error) ? i18n.t(e.error) : e.reason || e.message, i18n.t('Oops')); + log(e); + } +}; diff --git a/app/views/RoomView/services/getRoomHeaderFields.ts b/app/views/RoomView/services/getRoomHeaderFields.ts new file mode 100644 index 00000000000..6fb8f97ed21 --- /dev/null +++ b/app/views/RoomView/services/getRoomHeaderFields.ts @@ -0,0 +1,9 @@ +import { type IRoomViewState } from '../definitions'; + +export const getRoomHeaderFields = ( + room: IRoomViewState['room'] +): { teamMain: boolean; encrypted?: boolean; departmentId?: string } => ({ + teamMain: 'teamMain' in room ? !!room.teamMain : false, + encrypted: 'encrypted' in room ? room.encrypted : undefined, + departmentId: 'id' in room ? room.departmentId : undefined +}); diff --git a/app/views/RoomView/services/navigateToScreen.ts b/app/views/RoomView/services/navigateToScreen.ts new file mode 100644 index 00000000000..6aebbc430d9 --- /dev/null +++ b/app/views/RoomView/services/navigateToScreen.ts @@ -0,0 +1,38 @@ +import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import { type TNavigation } from '../../../stacks/stackType'; +import { type ChatsStackParamList } from '../../../stacks/types'; + +export type TRoomStackParamList = ChatsStackParamList & TNavigation; + +export type TRoomScreen = keyof TRoomStackParamList; + +export type TRoomStackNavigation = NativeStackNavigationProp; + +type TScreenParams = undefined extends TRoomStackParamList[Screen] + ? { params?: TRoomStackParamList[Screen] } + : { params: TRoomStackParamList[Screen] }; + +type TNavigateToScreenOptions = { + navigation: TRoomStackNavigation; + isMasterDetail: boolean; + screen: Screen; +} & TScreenParams; + +export const navigateToScreen = ({ + navigation, + isMasterDetail, + screen, + params +}: TNavigateToScreenOptions): void => { + if (isMasterDetail) { + const navigateToModal = navigation.navigate as ( + screen: 'ModalStackNavigator', + params: { screen: Screen; params?: TRoomStackParamList[Screen] } + ) => void; + navigateToModal('ModalStackNavigator', { screen, params }); + return; + } + const navigateDirect: (screen: Screen, params?: TRoomStackParamList[Screen]) => void = navigation.navigate; + navigateDirect(screen, params); +}; diff --git a/app/views/RoomView/services/placeLivechatOnHold.ts b/app/views/RoomView/services/placeLivechatOnHold.ts new file mode 100644 index 00000000000..6d46734cc75 --- /dev/null +++ b/app/views/RoomView/services/placeLivechatOnHold.ts @@ -0,0 +1,20 @@ +import i18n from '../../../i18n'; +import { showConfirmationAlert, showErrorAlert } from '../../../lib/methods/helpers'; +import { onHoldLivechat } from '../../../lib/services/restApi'; +import { type TRoomStackNavigation } from './navigateToScreen'; + +export const placeLivechatOnHold = ({ rid, navigation }: { rid: string; navigation: TRoomStackNavigation }): void => { + showConfirmationAlert({ + title: i18n.t('Are_you_sure_question_mark'), + message: i18n.t('Would_like_to_place_on_hold'), + confirmationText: i18n.t('Yes'), + onPress: async () => { + try { + await onHoldLivechat(rid); + navigation.navigate('RoomsListView'); + } catch (e: any) { + showErrorAlert(e.data?.error, i18n.t('Oops')); + } + } + }); +};