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'));
+ }
+ }
+ });
+};