From 65c3e73cc18ec6c3412109535b14ef43e9612f16 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 10:24:07 -0300 Subject: [PATCH 01/12] chore: add cognitive complexity lint rule via oxlint-plugin-complexity --- .oxlintrc.json | 6 ++-- package.json | 1 + patches/oxlint-plugin-complexity+2.1.8.patch | 16 +++++++++++ pnpm-lock.yaml | 30 +++++++++++++++++++- 4 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 patches/oxlint-plugin-complexity+2.1.8.patch diff --git a/.oxlintrc.json b/.oxlintrc.json index ef7e8a4af72..774e37f7550 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,7 +1,7 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": ["import", "react", "jest"], - "jsPlugins": ["eslint-plugin-react-native"], + "jsPlugins": ["eslint-plugin-react-native", "oxlint-plugin-complexity"], "categories": { "correctness": "off" }, @@ -46,7 +46,8 @@ "require-await": "error", "react/exhaustive-deps": "warn", "react/rules-of-hooks": "error", - "react/react-compiler": "warn" + "react/react-compiler": "warn", + "complexity/complexity": ["warn", { "cyclomatic": 1000, "cognitive": 15 }] }, "overrides": [ { @@ -97,6 +98,7 @@ "files": ["jest.setup.js", "__mocks__/**/*.js", "**/*.test.{js,ts,tsx}"], "rules": { "complexity": "off", + "complexity/complexity": "off", "max-depth": "off", "max-nested-callbacks": "off", "max-params": "off", diff --git a/package.json b/package.json index 2c4698ee28c..7827581860e 100644 --- a/package.json +++ b/package.json @@ -204,6 +204,7 @@ "jest-expo": "~54.0.16", "oxfmt": "^0.60.0", "oxlint": "^1.75.0", + "oxlint-plugin-complexity": "^2.1.8", "patch-package": "~8.0.1", "react-dom": "19.1.0", "react-native-dotenv": "3.4.8", diff --git a/patches/oxlint-plugin-complexity+2.1.8.patch b/patches/oxlint-plugin-complexity+2.1.8.patch new file mode 100644 index 00000000000..b2f4f34f093 --- /dev/null +++ b/patches/oxlint-plugin-complexity+2.1.8.patch @@ -0,0 +1,16 @@ +--- a/node_modules/oxlint-plugin-complexity/dist/combined-visitor.js ++++ b/node_modules/oxlint-plugin-complexity/dist/combined-visitor.js +@@ -21,13 +21,6 @@ + hasRecursiveCall: false, + }), + onEnterFunction(parentScope, node, _scope) { +- // Only add nested function penalty for functions inside other functions +- // (not for top-level arrow functions or callbacks) +- // The penalty is added to the PARENT scope, not the nested function's own scope +- if (parentScope && globalFunctionNestingLevel > 0) { +- const functionType = node.type === 'ArrowFunctionExpression' ? 'arrow function' : 'function'; +- parentScope.cognitivePoints.push(createComplexityPoint(node, `nested ${functionType}`)); +- } + globalFunctionNestingLevel++; + }, + onExitFunction(scope, node) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6113fa9567..0f840f2958e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -496,6 +496,9 @@ importers: oxlint: specifier: ^1.75.0 version: 1.75.0 + oxlint-plugin-complexity: + specifier: ^2.1.8 + version: 2.1.8 patch-package: specifier: ~8.0.1 version: 8.0.1 @@ -2325,6 +2328,10 @@ packages: cpu: [x64] os: [win32] + '@oxlint/plugins@1.82.0': + resolution: {integrity: sha512-IDF4UXBNOaCeLoEpVxf5Bg85KOWdeuBEkbo1rGzS2vXcbD+GT1asBSeeTC+5BgW6NFTHVByd6N5giHsFPGtwAA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2668,7 +2675,7 @@ packages: react-native: '*' '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/ecf4fedcf8ab6121e62a6b95bb48823afd805afd': - resolution: {gitHosted: true, integrity: sha512-+W+SeN/RcKRANWw8xjfl4916ldSrgEgVHTvabJwYNoxxLaOyrHs2rjeNk/0gxKO4fjMo0kd+lU0wck3QH4A6OA==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/ecf4fedcf8ab6121e62a6b95bb48823afd805afd} + resolution: {gitHosted: true, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/ecf4fedcf8ab6121e62a6b95bb48823afd805afd} version: 2.0.0-mobile '@rocket.chat/ui-kit@0.39.0': @@ -6130,6 +6137,21 @@ packages: vite-plus: optional: true + oxlint-plugin-complexity@2.1.8: + resolution: {integrity: sha512-4BnfNeQkeuEwUti+QAOBaCUuEWZaubk7L94dNl60RXzrs/pjzNOXhWx4rAmK8g/HoBrkIyJ8kqyto5nU0vDG9g==} + engines: {node: '>=20.0.0'} + peerDependencies: + diff: '>=5.0.0' + estree-walker: '>=3.0.0' + oxc-parser: '>=0.60.0' + peerDependenciesMeta: + diff: + optional: true + estree-walker: + optional: true + oxc-parser: + optional: true + oxlint@1.75.0: resolution: {integrity: sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -10147,6 +10169,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.75.0': optional: true + '@oxlint/plugins@1.82.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -14675,6 +14699,10 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.60.0 '@oxfmt/binding-win32-x64-msvc': 0.60.0 + oxlint-plugin-complexity@2.1.8: + dependencies: + '@oxlint/plugins': 1.82.0 + oxlint@1.75.0: optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.75.0 From 6a6b6b233b50ee0034556d8dc0391900a4b42bd2 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 11:17:20 -0300 Subject: [PATCH 02/12] test(RoomView): backfill RightButtons fixtures and snapshots --- .../RoomView/components/RightButtons.test.tsx | 125 --------- .../__tests__/RightButtons.test.tsx | 239 ++++++++++++++++++ .../__snapshots__/RightButtons.test.tsx.snap | 215 ++++++++++++++++ 3 files changed, 454 insertions(+), 125 deletions(-) delete mode 100644 app/views/RoomView/components/RightButtons.test.tsx create mode 100644 app/views/RoomView/components/__tests__/RightButtons.test.tsx create mode 100644 app/views/RoomView/components/__tests__/__snapshots__/RightButtons.test.tsx.snap 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/__tests__/RightButtons.test.tsx b/app/views/RoomView/components/__tests__/RightButtons.test.tsx new file mode 100644 index 00000000000..0b266bbaaa3 --- /dev/null +++ b/app/views/RoomView/components/__tests__/RightButtons.test.tsx @@ -0,0 +1,239 @@ +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 +})); + +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 }; +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('../HeaderCallButton', () => { + const ReactActual = jest.requireActual('react'); + return { + HeaderCallButton: (props: any) => ReactActual.createElement('CallButton', { ...props, testID: 'room-view-header-call' }) + }; +}); + +const allTestIDs = [ + 'room-view-search', + 'room-view-header-threads', + 'room-view-header-call', + '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 }; + 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, ['room-view-header-call', '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 }; + mockHeaderHooks = { ...mockHeaderHooks, canToggleEncryption: true }; + const { queryByTestId, toJSON } = render(); + expectOnly(queryByTestId, [ + 'room-view-header-encryption', + 'room-view-header-call', + '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: false, showE2EEDisabledRoom: true }; + const { queryByTestId, toJSON } = render(); + expectOnly(queryByTestId, [ + 'room-view-header-encryption', + 'room-view-header-call', + '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', + 'room-view-header-call', + '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', + 'room-view-header-call', + 'room-view-header-threads', + 'room-view-search' + ]); + expect(queryByTestId('room-view-push-troubleshoot')).toHaveProp('color', ''); + expect(toJSON()).toMatchSnapshot(); + }); + + 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..3ad59817e54 --- /dev/null +++ b/app/views/RoomView/components/__tests__/__snapshots__/RightButtons.test.tsx.snap @@ -0,0 +1,215 @@ +// 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 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 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`] = ` + + + +`; From 96d79956a539a942be6f180c191693f2257ce7df Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 11:21:51 -0300 Subject: [PATCH 03/12] refactor(RoomView): move RightButtons logic into hooks, services and navigation helper --- .../RoomView/__tests__/RoomGate.test.tsx | 8 +- .../RoomView/components/RightButtons.tsx | 110 ++---------------- .../__tests__/RightButtons.test.tsx | 8 +- app/views/RoomView/definitions.ts | 1 + .../hooks/__tests__/navigateToScreen.test.ts | 34 ++++++ app/views/RoomView/hooks/navigateToScreen.ts | 33 ++++++ app/views/RoomView/hooks/useE2EEStatus.ts | 4 +- .../services/__tests__/closeLivechat.test.ts | 95 +++++++++++++++ .../__tests__/placeLivechatOnHold.test.ts | 49 ++++++++ app/views/RoomView/services/closeLivechat.ts | 49 ++++++++ .../RoomView/services/getRoomHeaderFields.ts | 9 ++ .../RoomView/services/placeLivechatOnHold.ts | 20 ++++ 12 files changed, 309 insertions(+), 111 deletions(-) create mode 100644 app/views/RoomView/hooks/__tests__/navigateToScreen.test.ts create mode 100644 app/views/RoomView/hooks/navigateToScreen.ts create mode 100644 app/views/RoomView/services/__tests__/closeLivechat.test.ts create mode 100644 app/views/RoomView/services/__tests__/placeLivechatOnHold.test.ts create mode 100644 app/views/RoomView/services/closeLivechat.ts create mode 100644 app/views/RoomView/services/getRoomHeaderFields.ts create mode 100644 app/views/RoomView/services/placeLivechatOnHold.ts 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.tsx b/app/views/RoomView/components/RightButtons.tsx index c882cbb8ae6..00513265772 100644 --- a/app/views/RoomView/components/RightButtons.tsx +++ b/app/views/RoomView/components/RightButtons.tsx @@ -1,16 +1,12 @@ 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'; @@ -18,16 +14,18 @@ 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 { 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 { navigateToScreen, type TRoomStackNavigation } from '../hooks/navigateToScreen'; +import { closeLivechat } from '../services/closeLivechat'; +import { getRoomHeaderFields } from '../services/getRoomHeaderFields'; +import { placeLivechatOnHold } from '../services/placeLivechatOnHold'; import { toggleFollowThread } from '../../../lib/methods/toggleFollowThread'; import { type RoomStore } from '../definitions'; @@ -37,95 +35,8 @@ interface IRightButtonsProps { 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 navigation = useNavigation(); const isMasterDetail = useMasterDetail(); const { colors } = useTheme(); const { showActionSheet } = useActionSheet(); @@ -140,8 +51,7 @@ const RightButtons = ({ rid, tmid, roomStore }: IRightButtonsProps): ReactElemen const canReturnQueue = useCanReturnQueue(room.t === 'l'); const canPlaceLivechatOnHold = useCanPlaceLivechatOnHold(roomStore); - const { showMissingE2EEKey, showE2EEDisabledRoom } = useE2EEStatus(roomStore); - const hasE2EEWarning = !!('encrypted' in room && (showMissingE2EEKey || showE2EEDisabledRoom)); + const { hasE2EEWarning } = useE2EEStatus(roomStore); const isFollowingThread = useThreadFollowing(tmid, userId); const { tunread, tunreadUser, tunreadGroup, isSelfDm, subscription } = useSubscriptionUnreads(roomStore, userId); @@ -151,9 +61,7 @@ const RightButtons = ({ rid, tmid, roomStore }: IRightButtonsProps): ReactElemen 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 { teamMain, encrypted, departmentId } = getRoomHeaderFields(room); const goThreadsView = () => { logEvent(events.ROOM_GO_THREADS); @@ -186,7 +94,7 @@ const RightButtons = ({ rid, tmid, roomStore }: IRightButtonsProps): ReactElemen options.push({ title: i18n.t('Place_chat_on_hold'), icon: 'pause', - onPress: () => rid && placeOnHoldLivechat(rid, navigation) + onPress: () => rid && placeLivechatOnHold({ rid, navigation }) }); } diff --git a/app/views/RoomView/components/__tests__/RightButtons.test.tsx b/app/views/RoomView/components/__tests__/RightButtons.test.tsx index 0b266bbaaa3..d516a111777 100644 --- a/app/views/RoomView/components/__tests__/RightButtons.test.tsx +++ b/app/views/RoomView/components/__tests__/RightButtons.test.tsx @@ -41,7 +41,7 @@ jest.mock('zustand', () => ({ jest.mock('../../../../ee/omnichannel/hooks/useCanReturnQueue', () => ({ useCanReturnQueue: () => false })); jest.mock('../../hooks/useCanPlaceLivechatOnHold', () => ({ useCanPlaceLivechatOnHold: () => false })); -let mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: false }; +let mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: false, hasE2EEWarning: false }; jest.mock('../../hooks/useE2EEStatus', () => ({ useE2EEStatus: () => mockE2EEStatus })); let mockHeaderHooks = { @@ -110,7 +110,7 @@ describe('RightButtons', () => { room: { rid: 'rid-1', t: 'c', name: 'general' }, canForwardGuest: false }; - mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: false }; + mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: false, hasE2EEWarning: false }; mockHeaderHooks = { isFollowingThread: false, tunread: [], @@ -175,7 +175,7 @@ describe('RightButtons', () => { 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 }; + mockE2EEStatus = { showMissingE2EEKey: true, showE2EEDisabledRoom: false, hasE2EEWarning: true }; mockHeaderHooks = { ...mockHeaderHooks, canToggleEncryption: true }; const { queryByTestId, toJSON } = render(); expectOnly(queryByTestId, [ @@ -191,7 +191,7 @@ describe('RightButtons', () => { 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: false, showE2EEDisabledRoom: true }; + mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: true, hasE2EEWarning: true }; const { queryByTestId, toJSON } = render(); expectOnly(queryByTestId, [ 'room-view-header-encryption', 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 { + const navigate = jest.fn(); + const navigation = { navigate } as unknown as TRoomStackNavigation; + + beforeEach(() => jest.clearAllMocks()); + + it('navigates straight to the screen on stack mode', () => { + navigateToScreen({ + navigation, + isMasterDetail: false, + screen: 'ThreadMessagesView', + params: { rid: 'rid-1', t: 'c' } as any + }); + + 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: { rid: 'rid-1', t: 'c' } as any }); + + 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); + }); +}); diff --git a/app/views/RoomView/hooks/navigateToScreen.ts b/app/views/RoomView/hooks/navigateToScreen.ts new file mode 100644 index 00000000000..70e3a3ebff4 --- /dev/null +++ b/app/views/RoomView/hooks/navigateToScreen.ts @@ -0,0 +1,33 @@ +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; + +export const navigateToScreen = ({ + navigation, + isMasterDetail, + screen, + params +}: { + navigation: TRoomStackNavigation; + isMasterDetail: boolean; + screen: Screen; + params?: TRoomStackParamList[Screen]; +}): void => { + 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?: TRoomStackParamList[Screen]) => void = navigation.navigate; + navigateDirect(screen, params); +}; 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/services/__tests__/closeLivechat.test.ts b/app/views/RoomView/services/__tests__/closeLivechat.test.ts new file mode 100644 index 00000000000..61fd0beff04 --- /dev/null +++ b/app/views/RoomView/services/__tests__/closeLivechat.test.ts @@ -0,0 +1,95 @@ +import { closeLivechat as closeLivechatService } from '../../../../lib/methods/helpers/closeLivechat'; +import { getDepartmentInfo, getTagsList } from '../../../../lib/services/restApi'; +import { navigateToScreen, type TRoomStackNavigation } from '../../hooks/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('../../hooks/navigateToScreen', () => ({ navigateToScreen: 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('swallows request failures without navigating', async () => { + mockGetDepartmentInfo.mockRejectedValueOnce(new Error('offline')); + + await expect( + closeLivechat({ rid: 'rid-1', departmentId: 'dep-1', isMasterDetail: false, livechatRequestComment: false, navigation }) + ).resolves.toBeUndefined(); + + expect(mockCloseLivechatService).not.toHaveBeenCalled(); + expect(mockNavigateToScreen).not.toHaveBeenCalled(); + }); +}); 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..c366c2cf575 --- /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 '../../hooks/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..610c46fdfc4 --- /dev/null +++ b/app/views/RoomView/services/closeLivechat.ts @@ -0,0 +1,49 @@ +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 { getDepartmentInfo, getTagsList } from '../../../lib/services/restApi'; +import { navigateToScreen, type TRoomStackNavigation } from '../hooks/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 { + return undefined; + } +}; 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/placeLivechatOnHold.ts b/app/views/RoomView/services/placeLivechatOnHold.ts new file mode 100644 index 00000000000..21e76d461b0 --- /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 '../hooks/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')); + } + } + }); +}; From 99369af85290bbe62713ab43a9fdc8c631f7f63e Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 11:25:15 -0300 Subject: [PATCH 04/12] refactor(RoomView): split RightButtons into dispatcher and per-context components --- .../RoomView/components/RightButtons.tsx | 265 ------------------ .../{ => RightButtons}/HeaderCallButton.tsx | 6 +- .../RightButtons/OmnichannelRightButtons.tsx | 98 +++++++ .../components/RightButtons/RightButtons.tsx | 40 +++ .../RightButtons/RoomRightButtons.tsx | 143 ++++++++++ .../RightButtons/ThreadRightButtons.tsx | 30 ++ .../__tests__/RightButtons.test.tsx | 4 +- .../hooks/__tests__/useHeader.test.tsx | 2 +- app/views/RoomView/hooks/useHeader.tsx | 2 +- 9 files changed, 318 insertions(+), 272 deletions(-) delete mode 100644 app/views/RoomView/components/RightButtons.tsx rename app/views/RoomView/components/{ => RightButtons}/HeaderCallButton.tsx (88%) create mode 100644 app/views/RoomView/components/RightButtons/OmnichannelRightButtons.tsx create mode 100644 app/views/RoomView/components/RightButtons/RightButtons.tsx create mode 100644 app/views/RoomView/components/RightButtons/RoomRightButtons.tsx create mode 100644 app/views/RoomView/components/RightButtons/ThreadRightButtons.tsx diff --git a/app/views/RoomView/components/RightButtons.tsx b/app/views/RoomView/components/RightButtons.tsx deleted file mode 100644 index 00513265772..00000000000 --- a/app/views/RoomView/components/RightButtons.tsx +++ /dev/null @@ -1,265 +0,0 @@ -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 { type ISubscription, type SubscriptionType, type TUserStatus } from '../../../definitions'; -import i18n from '../../../i18n'; -import { getRoomTitle, isGroupChat, showConfirmationAlert, showErrorAlert } 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 { 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 { returnLivechat } from '../../../lib/services/restApi'; -import { getUserSelector } from '../../../selectors/login'; -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 { navigateToScreen, type TRoomStackNavigation } from '../hooks/navigateToScreen'; -import { closeLivechat } from '../services/closeLivechat'; -import { getRoomHeaderFields } from '../services/getRoomHeaderFields'; -import { placeLivechatOnHold } from '../services/placeLivechatOnHold'; -import { toggleFollowThread } from '../../../lib/methods/toggleFollowThread'; -import { type RoomStore } from '../definitions'; - -interface IRightButtonsProps { - rid?: string; - tmid?: string; - roomStore: RoomStore; -} - -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 { hasE2EEWarning } = useE2EEStatus(roomStore); - - 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, encrypted, departmentId } = getRoomHeaderFields(room); - - 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 && placeLivechatOnHold({ 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..edeb10598e3 --- /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 '../../hooks/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..ec1f5df632b --- /dev/null +++ b/app/views/RoomView/components/RightButtons/RightButtons.tsx @@ -0,0 +1,40 @@ +import { type ReactElement } from 'react'; +import { useStore } from 'zustand'; + +import { type RoomStore } from '../../definitions'; +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 = useStore(roomStore, s => s.room); + + 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..60a1afc953f --- /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 '../../hooks/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_SEARCH); + 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..c10549302ab --- /dev/null +++ b/app/views/RoomView/components/RightButtons/ThreadRightButtons.tsx @@ -0,0 +1,30 @@ +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'; + +export const ThreadRightButtons = ({ tmid }: { tmid: string }): 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/__tests__/RightButtons.test.tsx b/app/views/RoomView/components/__tests__/RightButtons.test.tsx index d516a111777..2b32ac017a7 100644 --- a/app/views/RoomView/components/__tests__/RightButtons.test.tsx +++ b/app/views/RoomView/components/__tests__/RightButtons.test.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react-native'; -import RightButtons from '../RightButtons'; +import RightButtons from '../RightButtons/RightButtons'; const mockNavigation = { navigate: jest.fn(), push: jest.fn() }; jest.mock('@react-navigation/native', () => ({ @@ -72,7 +72,7 @@ jest.mock('../../../../containers/Header/components/HeaderButton', () => { BadgeUnread: () => null }; }); -jest.mock('../HeaderCallButton', () => { +jest.mock('../RightButtons/HeaderCallButton', () => { const ReactActual = jest.requireActual('react'); return { HeaderCallButton: (props: any) => ReactActual.createElement('CallButton', { ...props, testID: 'room-view-header-call' }) 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/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'; From e055d6537377aa8b1ce0ae9c224a01b2e0c383e4 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 11:27:55 -0300 Subject: [PATCH 05/12] test(RoomView): tighten RightButtons fixtures and service tests --- .../__tests__/RightButtons.test.tsx | 57 +++++++++++++++--- .../__snapshots__/RightButtons.test.tsx.snap | 60 +++++++++++++++++-- .../hooks/__tests__/navigateToScreen.test.ts | 8 ++- app/views/RoomView/services/closeLivechat.ts | 4 +- 4 files changed, 111 insertions(+), 18 deletions(-) diff --git a/app/views/RoomView/components/__tests__/RightButtons.test.tsx b/app/views/RoomView/components/__tests__/RightButtons.test.tsx index 2b32ac017a7..f2994aaaac1 100644 --- a/app/views/RoomView/components/__tests__/RightButtons.test.tsx +++ b/app/views/RoomView/components/__tests__/RightButtons.test.tsx @@ -75,14 +75,15 @@ jest.mock('../../../../containers/Header/components/HeaderButton', () => { jest.mock('../RightButtons/HeaderCallButton', () => { const ReactActual = jest.requireActual('react'); return { - HeaderCallButton: (props: any) => ReactActual.createElement('CallButton', { ...props, testID: 'room-view-header-call' }) + 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', - 'room-view-header-call', + 'header-call-button-stub', 'room-view-header-encryption', 'room-view-push-troubleshoot', 'room-view-header-omnichannel-kebab', @@ -169,7 +170,7 @@ describe('RightButtons', () => { it('renders call, threads and search for a regular channel', () => { const { queryByTestId, toJSON } = render(); - expectOnly(queryByTestId, ['room-view-header-call', 'room-view-header-threads', 'room-view-search']); + expectOnly(queryByTestId, ['header-call-button-stub', 'room-view-header-threads', 'room-view-search']); expect(toJSON()).toMatchSnapshot(); }); @@ -180,7 +181,7 @@ describe('RightButtons', () => { const { queryByTestId, toJSON } = render(); expectOnly(queryByTestId, [ 'room-view-header-encryption', - 'room-view-header-call', + 'header-call-button-stub', 'room-view-header-threads', 'room-view-search' ]); @@ -190,12 +191,26 @@ describe('RightButtons', () => { }); 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', - 'room-view-header-call', + 'header-call-button-stub', 'room-view-header-threads', 'room-view-search' ]); @@ -208,7 +223,7 @@ describe('RightButtons', () => { const { queryByTestId, toJSON } = render(); expectOnly(queryByTestId, [ 'room-view-push-troubleshoot', - 'room-view-header-call', + 'header-call-button-stub', 'room-view-header-threads', 'room-view-search' ]); @@ -221,7 +236,7 @@ describe('RightButtons', () => { const { queryByTestId, toJSON } = render(); expectOnly(queryByTestId, [ 'room-view-push-troubleshoot', - 'room-view-header-call', + 'header-call-button-stub', 'room-view-header-threads', 'room-view-search' ]); @@ -229,6 +244,34 @@ describe('RightButtons', () => { 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 }; diff --git a/app/views/RoomView/components/__tests__/__snapshots__/RightButtons.test.tsx.snap b/app/views/RoomView/components/__tests__/__snapshots__/RightButtons.test.tsx.snap index 3ad59817e54..ed2a459107c 100644 --- a/app/views/RoomView/components/__tests__/__snapshots__/RightButtons.test.tsx.snap +++ b/app/views/RoomView/components/__tests__/__snapshots__/RightButtons.test.tsx.snap @@ -12,7 +12,7 @@ exports[`RightButtons disables the encryption button when the user cannot toggle accessibilityLabel="Call label" disabled={true} rid="rid-1" - testID="room-view-header-call" + testID="header-call-button-stub" /> `; +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 the encryption button when the room has e2ee disabled 1`] = ` + + + + + + +`; + exports[`RightButtons renders the follow button for an unfollowed thread 1`] = ` { const navigate = jest.fn(); const navigation = { navigate } as unknown as TRoomStackNavigation; + const threadMessagesParams: TRoomStackParamList['ThreadMessagesView'] = { rid: 'rid-1', t: SubscriptionType.CHANNEL }; beforeEach(() => jest.clearAllMocks()); @@ -11,14 +13,14 @@ describe('navigateToScreen', () => { navigation, isMasterDetail: false, screen: 'ThreadMessagesView', - params: { rid: 'rid-1', t: 'c' } as any + 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: { rid: 'rid-1', t: 'c' } as any }); + navigateToScreen({ navigation, isMasterDetail: true, screen: 'ThreadMessagesView', params: threadMessagesParams }); expect(navigate).toHaveBeenCalledWith('ModalStackNavigator', { screen: 'ThreadMessagesView', diff --git a/app/views/RoomView/services/closeLivechat.ts b/app/views/RoomView/services/closeLivechat.ts index 610c46fdfc4..22e613aff2b 100644 --- a/app/views/RoomView/services/closeLivechat.ts +++ b/app/views/RoomView/services/closeLivechat.ts @@ -43,7 +43,5 @@ export const closeLivechat = async ({ screen: 'CloseLivechatView', params: { rid, departmentId, departmentInfo, tagsList } }); - } catch { - return undefined; - } + } catch {} }; From cd31637bc73df44930d97f752e7db7577080869a Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 11:33:07 -0300 Subject: [PATCH 06/12] test(RoomView): add RightButtons dispatcher and per-component suites --- .../RightButtons/ThreadRightButtons.tsx | 6 +- .../__tests__/HeaderCallButton.test.tsx | 135 +++++++++ .../OmnichannelRightButtons.test.tsx | 190 ++++++++++++ .../__tests__/RightButtons.test.tsx | 157 ++++++++++ .../__tests__/RoomRightButtons.test.tsx | 276 ++++++++++++++++++ .../__tests__/ThreadRightButtons.test.tsx | 90 ++++++ 6 files changed, 853 insertions(+), 1 deletion(-) create mode 100644 app/views/RoomView/components/RightButtons/__tests__/HeaderCallButton.test.tsx create mode 100644 app/views/RoomView/components/RightButtons/__tests__/OmnichannelRightButtons.test.tsx create mode 100644 app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx create mode 100644 app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx create mode 100644 app/views/RoomView/components/RightButtons/__tests__/ThreadRightButtons.test.tsx diff --git a/app/views/RoomView/components/RightButtons/ThreadRightButtons.tsx b/app/views/RoomView/components/RightButtons/ThreadRightButtons.tsx index c10549302ab..da9dd99fa12 100644 --- a/app/views/RoomView/components/RightButtons/ThreadRightButtons.tsx +++ b/app/views/RoomView/components/RightButtons/ThreadRightButtons.tsx @@ -8,7 +8,11 @@ import { toggleFollowThread } from '../../../../lib/methods/toggleFollowThread'; import { getUserSelector } from '../../../../selectors/login'; import { useThreadFollowing } from '../../hooks/useThreadFollowing'; -export const ThreadRightButtons = ({ tmid }: { tmid: string }): ReactElement => { +interface IThreadRightButtonsProps { + tmid: string; +} + +export const ThreadRightButtons = ({ tmid }: IThreadRightButtonsProps): ReactElement => { const userId = useAppSelector(state => getUserSelector(state).id); const isFollowingThread = useThreadFollowing(tmid, userId); 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..4b3723ab539 --- /dev/null +++ b/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx @@ -0,0 +1,157 @@ +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) => createStore(() => ({ room })) as unknown as 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 = createStore(() => ({ room: { rid: 'rid-1', t: 'c' } as Record })); + 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('remounts the room buttons after the tmid is cleared', () => { + const roomStore = createRoomStore({ rid: 'rid-1', t: 'c' }); + render(); + + screen.rerender(); + + expectOnlyStub('room-right-buttons-stub'); + expect(roomMock.mounts.count).toBe(1); + 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..0b804045979 --- /dev/null +++ b/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx @@ -0,0 +1,276 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { type RoomStore } from '../../../definitions'; +import { RoomRightButtons } from '../RoomRightButtons'; + +const mockNavigation = { navigate: jest.fn(), push: 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('header-call-button-stub')).toHaveProp('rid', 'rid-1'); + 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; + + 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); + }); + + 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('colors the notification button and routes to the push troubleshoot screen on notification issues', () => { + 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('PushTroubleshootView', undefined); + }); + + it('routes to the notification preferences screen when notifications are disabled for the room', () => { + 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')); + expect(mockNavigation.navigate).toHaveBeenCalledWith('NotificationPrefView', { rid: 'rid-1', room: { id: 'rid-1' } }); + }); + + 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, search and encryption screens on stack mode', () => { + mockHasE2EEWarning = true; + mockCanToggleEncryption = 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('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 }); + + fireEvent.press(screen.getByTestId('room-view-header-encryption')); + expect(mockNavigation.navigate).toHaveBeenCalledWith('E2EEToggleRoomView', { rid: 'rid-1' }); + }); + + it('navigates through the modal stack on master-detail mode', () => { + mockIsMasterDetail = true; + mockHasE2EEWarning = true; + mockCanToggleEncryption = 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 } + }); + + fireEvent.press(screen.getByTestId('room-view-header-encryption')); + expect(mockNavigation.navigate).toHaveBeenCalledWith('ModalStackNavigator', { + screen: 'E2EEToggleRoomView', + params: { 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..7ef2762a67f --- /dev/null +++ b/app/views/RoomView/components/RightButtons/__tests__/ThreadRightButtons.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { toggleFollowThread } from '../../../../../lib/methods/toggleFollowThread'; +import { ThreadRightButtons } from '../ThreadRightButtons'; + +const mockAppState = { login: { user: { id: 'u1', username: 'user', token: 'tok' } } }; +jest.mock('../../../../../lib/hooks/useAppSelector', () => ({ + useAppSelector: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState) +})); + +let mockIsFollowingThread = false; +jest.mock('../../../hooks/useThreadFollowing', () => ({ + useThreadFollowing: (tmid: string, userId: string) => { + mockFollowingArgs.push({ tmid, userId }); + return mockIsFollowingThread; + } +})); +const mockFollowingArgs: { tmid: string; userId: string }[] = []; + +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(); + mockFollowingArgs.length = 0; + mockIsFollowingThread = false; + }); + + 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', () => { + mockIsFollowingThread = true; + + 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('reads the following state for the thread and the logged user', () => { + render(); + + expect(mockFollowingArgs[0]).toEqual({ tmid: 'tmid-1', userId: 'u1' }); + }); + + 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', () => { + mockIsFollowingThread = true; + + render(); + + fireEvent.press(screen.getByTestId('room-view-header-unfollow')); + + expect(toggleFollowThread).toHaveBeenCalledWith('tmid-1', true); + }); +}); From 0ec39a470d34cdbb31b8a9411b6c40c5a8a076f1 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 11:36:25 -0300 Subject: [PATCH 07/12] test(RoomView): assert the RightButtons remount and E2EE-disabled notification button --- .../RightButtons/__tests__/RightButtons.test.tsx | 14 +++++++++----- .../__tests__/RoomRightButtons.test.tsx | 3 ++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx b/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx index 4b3723ab539..77e3dd8e2b8 100644 --- a/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx +++ b/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx @@ -62,7 +62,10 @@ const expectOnlyStub = (present?: string) => { }); }; -const createRoomStore = (room: Record) => createStore(() => ({ room })) as unknown as RoomStore; +const createRoomStore = (room: Record) => { + const store = createStore(() => ({ room })); + return store as typeof store & RoomStore; +}; describe('RightButtons routing', () => { beforeEach(() => { @@ -117,8 +120,8 @@ describe('RightButtons routing', () => { }); it('swaps the room buttons for the omnichannel buttons when the room type changes in place', () => { - const roomStore = createStore(() => ({ room: { rid: 'rid-1', t: 'c' } as Record })); - render(); + const roomStore = createRoomStore({ rid: 'rid-1', t: 'c' }); + render(); expectOnlyStub('room-right-buttons-stub'); expect(roomMock.mounts.count).toBe(1); @@ -146,12 +149,13 @@ describe('RightButtons routing', () => { it('remounts the room buttons after the tmid is cleared', () => { const roomStore = createRoomStore({ rid: 'rid-1', t: 'c' }); - render(); + render(); + screen.rerender(); screen.rerender(); expectOnlyStub('room-right-buttons-stub'); - expect(roomMock.mounts.count).toBe(1); + 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 index 0b804045979..0eae6d49b09 100644 --- a/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx +++ b/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx @@ -116,7 +116,6 @@ describe('RoomRightButtons', () => { it('renders the call, threads and search buttons for a regular channel', () => { renderRoomRightButtons(); - expect(screen.getByTestId('header-call-button-stub')).toHaveProp('rid', 'rid-1'); 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(); @@ -142,6 +141,7 @@ describe('RoomRightButtons', () => { 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(); @@ -149,6 +149,7 @@ describe('RoomRightButtons', () => { 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', () => { From a511f9b6cfb7d66c6ba021795a8cd6d44399693c Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 13:55:08 -0300 Subject: [PATCH 08/12] fix(RoomView): refresh header routing on in-place room updates --- .../components/RightButtons/RightButtons.tsx | 4 +- .../__tests__/RightButtons.test.tsx | 19 +++++ .../__tests__/RoomRightButtons.test.tsx | 59 +++++++++++----- .../__tests__/ThreadRightButtons.test.tsx | 31 ++++---- .../hooks/__tests__/useE2EEStatus.test.ts | 70 +++++++++++++++++++ .../__tests__/getRoomHeaderFields.test.ts | 23 ++++++ package.json | 2 +- pnpm-lock.yaml | 4 +- 8 files changed, 177 insertions(+), 35 deletions(-) create mode 100644 app/views/RoomView/hooks/__tests__/useE2EEStatus.test.ts create mode 100644 app/views/RoomView/services/__tests__/getRoomHeaderFields.test.ts diff --git a/app/views/RoomView/components/RightButtons/RightButtons.tsx b/app/views/RoomView/components/RightButtons/RightButtons.tsx index ec1f5df632b..85637c76835 100644 --- a/app/views/RoomView/components/RightButtons/RightButtons.tsx +++ b/app/views/RoomView/components/RightButtons/RightButtons.tsx @@ -1,7 +1,7 @@ import { type ReactElement } from 'react'; -import { useStore } from 'zustand'; import { type RoomStore } from '../../definitions'; +import { useRoomWithUpdateFromStore } from '../../stores/RoomStoreContext'; import { OmnichannelRightButtons } from './OmnichannelRightButtons'; import { RoomRightButtons } from './RoomRightButtons'; import { ThreadRightButtons } from './ThreadRightButtons'; @@ -13,7 +13,7 @@ interface IRightButtonsProps { } const RightButtons = ({ rid, tmid, roomStore }: IRightButtonsProps): ReactElement | null => { - const room = useStore(roomStore, s => s.room); + const room = useRoomWithUpdateFromStore(roomStore); if (!rid) { return null; diff --git a/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx b/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx index 77e3dd8e2b8..d00a5d4996f 100644 --- a/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx +++ b/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx @@ -147,6 +147,25 @@ describe('RightButtons routing', () => { 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(); diff --git a/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx b/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx index 0eae6d49b09..499dc45c36e 100644 --- a/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx +++ b/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx @@ -82,7 +82,15 @@ jest.mock('../../../../../containers/Header/components/HeaderButton', () => { iconName: string; onPress: () => void; testID: string; - }) => ReactActual.createElement('Item', { accessibilityLabel, color, disabled, iconName, onPress, testID }), + }) => + ReactActual.createElement('Item', { + accessibilityLabel, + color, + disabled, + iconName, + onPress, + testID + }), BadgeUnread: () => null }; }); @@ -160,7 +168,8 @@ describe('RoomRightButtons', () => { expect(screen.getByTestId('room-view-header-encryption')).toHaveProp('disabled', true); }); - it('colors the notification button and routes to the push troubleshoot screen on notification issues', () => { + 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' } }; @@ -168,10 +177,15 @@ describe('RoomRightButtons', () => { expect(screen.getByTestId('room-view-push-troubleshoot')).toHaveProp('color', '#f00'); fireEvent.press(screen.getByTestId('room-view-push-troubleshoot')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('PushTroubleshootView', undefined); + expect(mockNavigation.navigate).toHaveBeenCalledWith( + ...(isMasterDetail + ? ['ModalStackNavigator', { screen: 'PushTroubleshootView', params: undefined }] + : ['PushTroubleshootView', undefined]) + ); }); - it('routes to the notification preferences screen when notifications are disabled for the room', () => { + 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' } }; @@ -179,7 +193,10 @@ describe('RoomRightButtons', () => { expect(screen.getByTestId('room-view-push-troubleshoot')).toHaveProp('color', ''); fireEvent.press(screen.getByTestId('room-view-push-troubleshoot')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('NotificationPrefView', { rid: 'rid-1', room: { id: 'rid-1' } }); + 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', () => { @@ -231,9 +248,7 @@ describe('RoomRightButtons', () => { expect(screen.getByTestId('room-view-header-threads')).toHaveProp('accessibilityLabel', 'Threads, 2 unread'); }); - it('navigates to the threads, search and encryption screens on stack mode', () => { - mockHasE2EEWarning = true; - mockCanToggleEncryption = true; + it('navigates to the threads and search screens on stack mode', () => { mockRoomState = { room: { rid: 'rid-1', t: 'c', name: 'general', encrypted: true } }; renderRoomRightButtons(); @@ -243,15 +258,10 @@ describe('RoomRightButtons', () => { fireEvent.press(screen.getByTestId('room-view-search')); expect(mockNavigation.navigate).toHaveBeenCalledWith('SearchMessagesView', { rid: 'rid-1', t: 'c', encrypted: true }); - - fireEvent.press(screen.getByTestId('room-view-header-encryption')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('E2EEToggleRoomView', { rid: 'rid-1' }); }); it('navigates through the modal stack on master-detail mode', () => { mockIsMasterDetail = true; - mockHasE2EEWarning = true; - mockCanToggleEncryption = true; mockRoomState = { room: { rid: 'rid-1', t: 'c', name: 'general', encrypted: true } }; renderRoomRightButtons(); @@ -267,11 +277,26 @@ describe('RoomRightButtons', () => { 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(mockNavigation.navigate).toHaveBeenCalledWith('ModalStackNavigator', { - screen: 'E2EEToggleRoomView', - params: { rid: 'rid-1' } - }); + 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 index 7ef2762a67f..c08922da070 100644 --- a/app/views/RoomView/components/RightButtons/__tests__/ThreadRightButtons.test.tsx +++ b/app/views/RoomView/components/RightButtons/__tests__/ThreadRightButtons.test.tsx @@ -3,19 +3,15 @@ import { fireEvent, render, screen } from '@testing-library/react-native'; import { toggleFollowThread } from '../../../../../lib/methods/toggleFollowThread'; import { ThreadRightButtons } from '../ThreadRightButtons'; -const mockAppState = { login: { user: { id: 'u1', username: 'user', token: 'tok' } } }; +let mockAppState = { login: { user: { id: 'u1', username: 'user', token: 'tok' } } }; jest.mock('../../../../../lib/hooks/useAppSelector', () => ({ useAppSelector: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState) })); -let mockIsFollowingThread = false; +let mockFollowersByThread: Record = {}; jest.mock('../../../hooks/useThreadFollowing', () => ({ - useThreadFollowing: (tmid: string, userId: string) => { - mockFollowingArgs.push({ tmid, userId }); - return mockIsFollowingThread; - } + useThreadFollowing: (tmid: string, userId: string) => mockFollowersByThread[tmid]?.includes(userId) ?? false })); -const mockFollowingArgs: { tmid: string; userId: string }[] = []; jest.mock('../../../../../lib/methods/toggleFollowThread', () => ({ toggleFollowThread: jest.fn() })); @@ -40,8 +36,8 @@ jest.mock('../../../../../containers/Header/components/HeaderButton', () => { describe('ThreadRightButtons', () => { beforeEach(() => { jest.clearAllMocks(); - mockFollowingArgs.length = 0; - mockIsFollowingThread = false; + mockFollowersByThread = {}; + mockAppState = { login: { user: { id: 'u1', username: 'user', token: 'tok' } } }; }); it('renders the follow button when the thread is not followed', () => { @@ -54,7 +50,7 @@ describe('ThreadRightButtons', () => { }); it('renders the unfollow button when the thread is followed', () => { - mockIsFollowingThread = true; + mockFollowersByThread = { 'tmid-1': ['u1'] }; render(); @@ -64,10 +60,19 @@ describe('ThreadRightButtons', () => { expect(screen.queryByTestId('room-view-header-follow')).not.toBeOnTheScreen(); }); - it('reads the following state for the thread and the logged user', () => { + 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(); - expect(mockFollowingArgs[0]).toEqual({ tmid: 'tmid-1', userId: 'u1' }); + 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', () => { @@ -79,7 +84,7 @@ describe('ThreadRightButtons', () => { }); it('unfollows the thread when it is followed', () => { - mockIsFollowingThread = true; + mockFollowersByThread = { 'tmid-1': ['u1'] }; render(); diff --git a/app/views/RoomView/hooks/__tests__/useE2EEStatus.test.ts b/app/views/RoomView/hooks/__tests__/useE2EEStatus.test.ts new file mode 100644 index 00000000000..23f0a52bb73 --- /dev/null +++ b/app/views/RoomView/hooks/__tests__/useE2EEStatus.test.ts @@ -0,0 +1,70 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { createStore } from 'zustand'; + +import { type IRoomViewState, type RoomStore } from '../../definitions'; +import { useE2EEStatus } from '../useE2EEStatus'; + +let mockState = { + server: { version: '7.0.0' }, + settings: { E2E_Enable: true }, + encryption: { enabled: true } +}; +jest.mock('../../../../lib/hooks/useAppSelector', () => ({ + 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/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/package.json b/package.json index 7827581860e..37cef44e093 100644 --- a/package.json +++ b/package.json @@ -204,7 +204,7 @@ "jest-expo": "~54.0.16", "oxfmt": "^0.60.0", "oxlint": "^1.75.0", - "oxlint-plugin-complexity": "^2.1.8", + "oxlint-plugin-complexity": "2.1.8", "patch-package": "~8.0.1", "react-dom": "19.1.0", "react-native-dotenv": "3.4.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f840f2958e..2b31c251744 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -497,7 +497,7 @@ importers: specifier: ^1.75.0 version: 1.75.0 oxlint-plugin-complexity: - specifier: ^2.1.8 + specifier: 2.1.8 version: 2.1.8 patch-package: specifier: ~8.0.1 @@ -2675,7 +2675,7 @@ packages: react-native: '*' '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/ecf4fedcf8ab6121e62a6b95bb48823afd805afd': - resolution: {gitHosted: true, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/ecf4fedcf8ab6121e62a6b95bb48823afd805afd} + resolution: {gitHosted: true, integrity: sha512-+W+SeN/RcKRANWw8xjfl4916ldSrgEgVHTvabJwYNoxxLaOyrHs2rjeNk/0gxKO4fjMo0kd+lU0wck3QH4A6OA==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/ecf4fedcf8ab6121e62a6b95bb48823afd805afd} version: 2.0.0-mobile '@rocket.chat/ui-kit@0.39.0': From d89f7b68557c05d1adff2e0a415d13ff4ec10135 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 14:10:54 -0300 Subject: [PATCH 09/12] fix(RoomView): address header analytics and close failure reviews --- app/lib/methods/helpers/log/events.ts | 1 + .../RightButtons/RoomRightButtons.tsx | 2 +- .../__tests__/RoomRightButtons.test.tsx | 7 ++++ .../services/__tests__/closeLivechat.test.ts | 36 +++++++++++++++++-- app/views/RoomView/services/closeLivechat.ts | 7 +++- 5 files changed, 49 insertions(+), 4 deletions(-) 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/components/RightButtons/RoomRightButtons.tsx b/app/views/RoomView/components/RightButtons/RoomRightButtons.tsx index 60a1afc953f..2a89d4d0c1f 100644 --- a/app/views/RoomView/components/RightButtons/RoomRightButtons.tsx +++ b/app/views/RoomView/components/RightButtons/RoomRightButtons.tsx @@ -73,7 +73,7 @@ export const RoomRightButtons = ({ rid, roomStore }: IRoomRightButtonsProps): Re }; const goE2EEToggleRoomView = () => { - logEvent(events.ROOM_GO_SEARCH); + logEvent(events.ROOM_GO_E2EE); navigateToScreen({ navigation, isMasterDetail, screen: 'E2EEToggleRoomView', params: { rid } }); }; diff --git a/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx b/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx index 499dc45c36e..29a4d923a86 100644 --- a/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx +++ b/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx @@ -1,9 +1,14 @@ 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 })); @@ -293,6 +298,8 @@ describe('RoomRightButtons', () => { 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' } }] diff --git a/app/views/RoomView/services/__tests__/closeLivechat.test.ts b/app/views/RoomView/services/__tests__/closeLivechat.test.ts index 61fd0beff04..595b6524881 100644 --- a/app/views/RoomView/services/__tests__/closeLivechat.test.ts +++ b/app/views/RoomView/services/__tests__/closeLivechat.test.ts @@ -1,4 +1,6 @@ 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 '../../hooks/navigateToScreen'; import { closeLivechat } from '../closeLivechat'; @@ -9,6 +11,8 @@ jest.mock('../../../../lib/services/restApi', () => ({ getTagsList: jest.fn() })); jest.mock('../../hooks/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; @@ -82,13 +86,41 @@ describe('closeLivechat', () => { expect(mockNavigateToScreen).not.toHaveBeenCalled(); }); - it('swallows request failures without navigating', async () => { - mockGetDepartmentInfo.mockRejectedValueOnce(new Error('offline')); + 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/closeLivechat.ts b/app/views/RoomView/services/closeLivechat.ts index 22e613aff2b..2bd4930a5d0 100644 --- a/app/views/RoomView/services/closeLivechat.ts +++ b/app/views/RoomView/services/closeLivechat.ts @@ -2,6 +2,8 @@ import { type ILivechatDepartment } from '../../../definitions/ILivechatDepartme 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 '../hooks/navigateToScreen'; @@ -43,5 +45,8 @@ export const closeLivechat = async ({ screen: 'CloseLivechatView', params: { rid, departmentId, departmentInfo, tagsList } }); - } catch {} + } catch (e: any) { + showErrorAlert(i18n.isTranslated(e.error) ? i18n.t(e.error) : e.reason || e.message, i18n.t('Oops')); + log(e); + } }; From 8bcbea8cc1e5d6966683e5bb6a95ca2339388971 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 14:24:20 -0300 Subject: [PATCH 10/12] Revert "chore: add cognitive complexity lint rule via oxlint-plugin-complexity" This reverts commit 65c3e73cc18ec6c3412109535b14ef43e9612f16. --- .oxlintrc.json | 6 ++--- package.json | 1 - patches/oxlint-plugin-complexity+2.1.8.patch | 16 ----------- pnpm-lock.yaml | 28 -------------------- 4 files changed, 2 insertions(+), 49 deletions(-) delete mode 100644 patches/oxlint-plugin-complexity+2.1.8.patch diff --git a/.oxlintrc.json b/.oxlintrc.json index 774e37f7550..ef7e8a4af72 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,7 +1,7 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": ["import", "react", "jest"], - "jsPlugins": ["eslint-plugin-react-native", "oxlint-plugin-complexity"], + "jsPlugins": ["eslint-plugin-react-native"], "categories": { "correctness": "off" }, @@ -46,8 +46,7 @@ "require-await": "error", "react/exhaustive-deps": "warn", "react/rules-of-hooks": "error", - "react/react-compiler": "warn", - "complexity/complexity": ["warn", { "cyclomatic": 1000, "cognitive": 15 }] + "react/react-compiler": "warn" }, "overrides": [ { @@ -98,7 +97,6 @@ "files": ["jest.setup.js", "__mocks__/**/*.js", "**/*.test.{js,ts,tsx}"], "rules": { "complexity": "off", - "complexity/complexity": "off", "max-depth": "off", "max-nested-callbacks": "off", "max-params": "off", diff --git a/package.json b/package.json index 37cef44e093..2c4698ee28c 100644 --- a/package.json +++ b/package.json @@ -204,7 +204,6 @@ "jest-expo": "~54.0.16", "oxfmt": "^0.60.0", "oxlint": "^1.75.0", - "oxlint-plugin-complexity": "2.1.8", "patch-package": "~8.0.1", "react-dom": "19.1.0", "react-native-dotenv": "3.4.8", diff --git a/patches/oxlint-plugin-complexity+2.1.8.patch b/patches/oxlint-plugin-complexity+2.1.8.patch deleted file mode 100644 index b2f4f34f093..00000000000 --- a/patches/oxlint-plugin-complexity+2.1.8.patch +++ /dev/null @@ -1,16 +0,0 @@ ---- a/node_modules/oxlint-plugin-complexity/dist/combined-visitor.js -+++ b/node_modules/oxlint-plugin-complexity/dist/combined-visitor.js -@@ -21,13 +21,6 @@ - hasRecursiveCall: false, - }), - onEnterFunction(parentScope, node, _scope) { -- // Only add nested function penalty for functions inside other functions -- // (not for top-level arrow functions or callbacks) -- // The penalty is added to the PARENT scope, not the nested function's own scope -- if (parentScope && globalFunctionNestingLevel > 0) { -- const functionType = node.type === 'ArrowFunctionExpression' ? 'arrow function' : 'function'; -- parentScope.cognitivePoints.push(createComplexityPoint(node, `nested ${functionType}`)); -- } - globalFunctionNestingLevel++; - }, - onExitFunction(scope, node) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b31c251744..f6113fa9567 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -496,9 +496,6 @@ importers: oxlint: specifier: ^1.75.0 version: 1.75.0 - oxlint-plugin-complexity: - specifier: 2.1.8 - version: 2.1.8 patch-package: specifier: ~8.0.1 version: 8.0.1 @@ -2328,10 +2325,6 @@ packages: cpu: [x64] os: [win32] - '@oxlint/plugins@1.82.0': - resolution: {integrity: sha512-IDF4UXBNOaCeLoEpVxf5Bg85KOWdeuBEkbo1rGzS2vXcbD+GT1asBSeeTC+5BgW6NFTHVByd6N5giHsFPGtwAA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -6137,21 +6130,6 @@ packages: vite-plus: optional: true - oxlint-plugin-complexity@2.1.8: - resolution: {integrity: sha512-4BnfNeQkeuEwUti+QAOBaCUuEWZaubk7L94dNl60RXzrs/pjzNOXhWx4rAmK8g/HoBrkIyJ8kqyto5nU0vDG9g==} - engines: {node: '>=20.0.0'} - peerDependencies: - diff: '>=5.0.0' - estree-walker: '>=3.0.0' - oxc-parser: '>=0.60.0' - peerDependenciesMeta: - diff: - optional: true - estree-walker: - optional: true - oxc-parser: - optional: true - oxlint@1.75.0: resolution: {integrity: sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -10169,8 +10147,6 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.75.0': optional: true - '@oxlint/plugins@1.82.0': {} - '@pkgjs/parseargs@0.11.0': optional: true @@ -14699,10 +14675,6 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.60.0 '@oxfmt/binding-win32-x64-msvc': 0.60.0 - oxlint-plugin-complexity@2.1.8: - dependencies: - '@oxlint/plugins': 1.82.0 - oxlint@1.75.0: optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.75.0 From c098f1726316495b9f46d6afc1d1f78b60210013 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 14:25:02 -0300 Subject: [PATCH 11/12] refactor(RoomView): move navigateToScreen into services --- .../components/RightButtons/OmnichannelRightButtons.tsx | 2 +- .../RoomView/components/RightButtons/RoomRightButtons.tsx | 2 +- app/views/RoomView/services/__tests__/closeLivechat.test.ts | 4 ++-- .../{hooks => services}/__tests__/navigateToScreen.test.ts | 0 .../RoomView/services/__tests__/placeLivechatOnHold.test.ts | 2 +- app/views/RoomView/services/closeLivechat.ts | 2 +- app/views/RoomView/{hooks => services}/navigateToScreen.ts | 0 app/views/RoomView/services/placeLivechatOnHold.ts | 2 +- 8 files changed, 7 insertions(+), 7 deletions(-) rename app/views/RoomView/{hooks => services}/__tests__/navigateToScreen.test.ts (100%) rename app/views/RoomView/{hooks => services}/navigateToScreen.ts (100%) diff --git a/app/views/RoomView/components/RightButtons/OmnichannelRightButtons.tsx b/app/views/RoomView/components/RightButtons/OmnichannelRightButtons.tsx index edeb10598e3..8020e2b7991 100644 --- a/app/views/RoomView/components/RightButtons/OmnichannelRightButtons.tsx +++ b/app/views/RoomView/components/RightButtons/OmnichannelRightButtons.tsx @@ -13,7 +13,7 @@ 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 '../../hooks/navigateToScreen'; +import { navigateToScreen, type TRoomStackNavigation } from '../../services/navigateToScreen'; import { closeLivechat } from '../../services/closeLivechat'; import { getRoomHeaderFields } from '../../services/getRoomHeaderFields'; import { placeLivechatOnHold } from '../../services/placeLivechatOnHold'; diff --git a/app/views/RoomView/components/RightButtons/RoomRightButtons.tsx b/app/views/RoomView/components/RightButtons/RoomRightButtons.tsx index 2a89d4d0c1f..02d4f88d6a0 100644 --- a/app/views/RoomView/components/RightButtons/RoomRightButtons.tsx +++ b/app/views/RoomView/components/RightButtons/RoomRightButtons.tsx @@ -17,7 +17,7 @@ import { useTheme } from '../../../../theme'; import { type RoomStore } from '../../definitions'; import { useE2EEStatus } from '../../hooks/useE2EEStatus'; import { useSubscriptionUnreads } from '../../hooks/useSubscriptionUnreads'; -import { navigateToScreen, type TRoomStackNavigation } from '../../hooks/navigateToScreen'; +import { navigateToScreen, type TRoomStackNavigation } from '../../services/navigateToScreen'; import { getRoomHeaderFields } from '../../services/getRoomHeaderFields'; import { HeaderCallButton } from './HeaderCallButton'; diff --git a/app/views/RoomView/services/__tests__/closeLivechat.test.ts b/app/views/RoomView/services/__tests__/closeLivechat.test.ts index 595b6524881..b6c196f0afb 100644 --- a/app/views/RoomView/services/__tests__/closeLivechat.test.ts +++ b/app/views/RoomView/services/__tests__/closeLivechat.test.ts @@ -2,7 +2,7 @@ import { closeLivechat as closeLivechatService } from '../../../../lib/methods/h 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 '../../hooks/navigateToScreen'; +import { navigateToScreen, type TRoomStackNavigation } from '../navigateToScreen'; import { closeLivechat } from '../closeLivechat'; jest.mock('../../../../lib/methods/helpers/closeLivechat', () => ({ closeLivechat: jest.fn(() => Promise.resolve()) })); @@ -10,7 +10,7 @@ jest.mock('../../../../lib/services/restApi', () => ({ getDepartmentInfo: jest.fn(), getTagsList: jest.fn() })); -jest.mock('../../hooks/navigateToScreen', () => ({ navigateToScreen: 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() })); diff --git a/app/views/RoomView/hooks/__tests__/navigateToScreen.test.ts b/app/views/RoomView/services/__tests__/navigateToScreen.test.ts similarity index 100% rename from app/views/RoomView/hooks/__tests__/navigateToScreen.test.ts rename to app/views/RoomView/services/__tests__/navigateToScreen.test.ts diff --git a/app/views/RoomView/services/__tests__/placeLivechatOnHold.test.ts b/app/views/RoomView/services/__tests__/placeLivechatOnHold.test.ts index c366c2cf575..6aa5273d5e2 100644 --- a/app/views/RoomView/services/__tests__/placeLivechatOnHold.test.ts +++ b/app/views/RoomView/services/__tests__/placeLivechatOnHold.test.ts @@ -1,7 +1,7 @@ import { showConfirmationAlert, showErrorAlert } from '../../../../lib/methods/helpers'; import { onHoldLivechat } from '../../../../lib/services/restApi'; import { placeLivechatOnHold } from '../placeLivechatOnHold'; -import { type TRoomStackNavigation } from '../../hooks/navigateToScreen'; +import { type TRoomStackNavigation } from '../navigateToScreen'; jest.mock('../../../../lib/methods/helpers', () => ({ showConfirmationAlert: jest.fn(), diff --git a/app/views/RoomView/services/closeLivechat.ts b/app/views/RoomView/services/closeLivechat.ts index 2bd4930a5d0..7f118ea2eac 100644 --- a/app/views/RoomView/services/closeLivechat.ts +++ b/app/views/RoomView/services/closeLivechat.ts @@ -5,7 +5,7 @@ import { closeLivechat as closeLivechatService } from '../../../lib/methods/help 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 '../hooks/navigateToScreen'; +import { navigateToScreen, type TRoomStackNavigation } from './navigateToScreen'; export const closeLivechat = async ({ rid, diff --git a/app/views/RoomView/hooks/navigateToScreen.ts b/app/views/RoomView/services/navigateToScreen.ts similarity index 100% rename from app/views/RoomView/hooks/navigateToScreen.ts rename to app/views/RoomView/services/navigateToScreen.ts diff --git a/app/views/RoomView/services/placeLivechatOnHold.ts b/app/views/RoomView/services/placeLivechatOnHold.ts index 21e76d461b0..6d46734cc75 100644 --- a/app/views/RoomView/services/placeLivechatOnHold.ts +++ b/app/views/RoomView/services/placeLivechatOnHold.ts @@ -1,7 +1,7 @@ import i18n from '../../../i18n'; import { showConfirmationAlert, showErrorAlert } from '../../../lib/methods/helpers'; import { onHoldLivechat } from '../../../lib/services/restApi'; -import { type TRoomStackNavigation } from '../hooks/navigateToScreen'; +import { type TRoomStackNavigation } from './navigateToScreen'; export const placeLivechatOnHold = ({ rid, navigation }: { rid: string; navigation: TRoomStackNavigation }): void => { showConfirmationAlert({ From c920fc80c856ff311a053487a60b99987a1ac8be Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 14:37:19 -0300 Subject: [PATCH 12/12] fix(RoomView): require navigateToScreen params for routes that need them --- .../__tests__/navigateToScreen.test.ts | 7 +++++++ .../RoomView/services/navigateToScreen.ts | 19 ++++++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/app/views/RoomView/services/__tests__/navigateToScreen.test.ts b/app/views/RoomView/services/__tests__/navigateToScreen.test.ts index 3d7bc23c7e4..666ff15aeb4 100644 --- a/app/views/RoomView/services/__tests__/navigateToScreen.test.ts +++ b/app/views/RoomView/services/__tests__/navigateToScreen.test.ts @@ -33,4 +33,11 @@ describe('navigateToScreen', () => { 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/navigateToScreen.ts b/app/views/RoomView/services/navigateToScreen.ts index 70e3a3ebff4..6aebbc430d9 100644 --- a/app/views/RoomView/services/navigateToScreen.ts +++ b/app/views/RoomView/services/navigateToScreen.ts @@ -9,21 +9,26 @@ 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 -}: { - navigation: TRoomStackNavigation; - isMasterDetail: boolean; - screen: Screen; - params?: TRoomStackParamList[Screen]; -}): void => { +}: TNavigateToScreenOptions): void => { if (isMasterDetail) { const navigateToModal = navigation.navigate as ( screen: 'ModalStackNavigator', - params: { screen: Screen; params?: typeof params } + params: { screen: Screen; params?: TRoomStackParamList[Screen] } ) => void; navigateToModal('ModalStackNavigator', { screen, params }); return;