diff --git a/apps/meteor/client/hooks/roomActions/useMediaCallRoomAction.spec.tsx b/apps/meteor/client/hooks/roomActions/useMediaCallRoomAction.spec.tsx new file mode 100644 index 0000000000000..78de9bcf37067 --- /dev/null +++ b/apps/meteor/client/hooks/roomActions/useMediaCallRoomAction.spec.tsx @@ -0,0 +1,146 @@ +import type { IUser, IRoom } from '@rocket.chat/core-typings'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; +import { useMediaCallAction } from '@rocket.chat/ui-voip'; +import { act, renderHook } from '@testing-library/react'; + +import { useMediaCallRoomAction } from './useMediaCallRoomAction'; +import FakeRoomProvider from '../../../tests/mocks/client/FakeRoomProvider'; +import { createFakeRoom, createFakeSubscription, createFakeUser } from '../../../tests/mocks/data'; + +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useUserAvatarPath: jest.fn((_args: any) => 'avatar-url'), +})); + +jest.mock('@rocket.chat/ui-voip', () => ({ + useMediaCallAction: jest.fn(), +})); + +const getUserInfoMocked = jest.fn().mockResolvedValue({ user: createFakeUser({ _id: 'peer-uid', username: 'peer-username' }) }); + +const appRoot = (overrides: { user?: IUser | null; room?: IRoom; subscription?: SubscriptionWithRoom } = {}) => { + const { + user = createFakeUser({ _id: 'own-uid', username: 'own-username' }), + room = createFakeRoom({ uids: ['own-uid', 'peer-uid'] }), + subscription = createFakeSubscription(), + } = overrides; + + const root = mockAppRoot() + .withRoom(room) + .withEndpoint('GET', '/v1/users.info', getUserInfoMocked) + .wrap((children) => ( + + {children} + + )); + + if (user !== null) { + root.withUser(user); + } + + return root.build(); +}; + +describe('useMediaCallRoomAction', () => { + const useMediaCallActionMocked = jest.mocked(useMediaCallAction); + + beforeEach(() => { + jest.clearAllMocks(); + + useMediaCallActionMocked.mockReturnValue({ + action: jest.fn(), + title: 'Start_call', + icon: 'phone', + }); + }); + + it('should return undefined if ownUserId is not defined', () => { + const { result } = renderHook(() => useMediaCallRoomAction(), { + wrapper: appRoot({ user: null }), + }); + + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if there are no other users in the room', () => { + const fakeRoom = createFakeRoom({ uids: ['own-uid'] }); + const { result } = renderHook(() => useMediaCallRoomAction(), { + wrapper: appRoot({ room: fakeRoom }), + }); + + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if there are more than one other user (Group DM)', () => { + const fakeRoom = createFakeRoom({ uids: ['own-uid', 'peer-uid-1', 'peer-uid-2'] }); + const { result } = renderHook(() => useMediaCallRoomAction(), { + wrapper: appRoot({ room: fakeRoom }), + }); + + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if callAction is undefined', () => { + useMediaCallActionMocked.mockReturnValue(undefined); + + const { result } = renderHook(() => useMediaCallRoomAction(), { + wrapper: appRoot(), + }); + + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if subscription is blocked', () => { + const fakeBlockedSubscription = createFakeSubscription({ blocker: false, blocked: true }); + const { result } = renderHook(() => useMediaCallRoomAction(), { + wrapper: appRoot({ subscription: fakeBlockedSubscription }), + }); + + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if subscription is blocker', () => { + const fakeBlockedSubscription = createFakeSubscription({ blocked: false, blocker: true }); + const { result } = renderHook(() => useMediaCallRoomAction(), { + wrapper: appRoot({ subscription: fakeBlockedSubscription }), + }); + + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if room is federated', () => { + const fakeFederatedRoom = createFakeRoom({ uids: ['own-uid', 'peer-uid'], federated: true }); + const { result } = renderHook(() => useMediaCallRoomAction(), { + wrapper: appRoot({ room: fakeFederatedRoom }), + }); + + expect(result.current).toBeUndefined(); + }); + + it('should return the action config if all conditions are met', () => { + const actionMock = jest.fn(); + useMediaCallActionMocked.mockReturnValue({ + action: actionMock, + title: 'Start_call', + icon: 'phone', + }); + + const { result } = renderHook(() => useMediaCallRoomAction(), { + wrapper: appRoot(), + }); + + expect(result.current).toEqual({ + id: 'start-voice-call', + title: 'Start_call', + icon: 'phone', + featured: true, + action: expect.any(Function), + groups: ['direct'], + }); + + // Test the action trigger + act(() => result.current?.action?.()); + expect(actionMock).toHaveBeenCalled(); + }); +}); diff --git a/apps/meteor/client/hooks/roomActions/useMediaCallRoomAction.ts b/apps/meteor/client/hooks/roomActions/useMediaCallRoomAction.ts index d0e458662251c..a4652eebf2fdd 100644 --- a/apps/meteor/client/hooks/roomActions/useMediaCallRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useMediaCallRoomAction.ts @@ -1,3 +1,4 @@ +import { isRoomFederated } from '@rocket.chat/core-typings'; import { useUserAvatarPath, useUserId } from '@rocket.chat/ui-contexts'; import type { TranslationKey, RoomToolboxActionConfig } from '@rocket.chat/ui-contexts'; import type { PeerInfo } from '@rocket.chat/ui-voip'; @@ -23,9 +24,11 @@ const getPeerId = (uids: string[], ownUserId: string | undefined) => { }; export const useMediaCallRoomAction = () => { - const { uids = [] } = useRoom(); + const room = useRoom(); + const { uids = [] } = room; const subscription = useRoomSubscription(); const ownUserId = useUserId(); + const federated = isRoomFederated(room); const getAvatarUrl = useUserAvatarPath(); @@ -52,7 +55,7 @@ export const useMediaCallRoomAction = () => { const blocked = subscription?.blocked || subscription?.blocker; return useMemo((): RoomToolboxActionConfig | undefined => { - if (!peerId || !callAction || blocked) { + if (!peerId || !callAction || blocked || federated) { return undefined; } @@ -66,5 +69,5 @@ export const useMediaCallRoomAction = () => { action: () => action(), groups: ['direct'] as const, }; - }, [peerId, callAction, blocked]); + }, [peerId, callAction, blocked, federated]); }; diff --git a/apps/meteor/client/views/navigation/sidepanel/hooks/useRoomMenuActions.spec.ts b/apps/meteor/client/views/navigation/sidepanel/hooks/useRoomMenuActions.spec.ts index 01de2f9d2d031..9001f6de9fb7a 100644 --- a/apps/meteor/client/views/navigation/sidepanel/hooks/useRoomMenuActions.spec.ts +++ b/apps/meteor/client/views/navigation/sidepanel/hooks/useRoomMenuActions.spec.ts @@ -1,12 +1,10 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; -import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { renderHook } from '@testing-library/react'; import { useRoomMenuActions } from './useRoomMenuActions'; -import { createFakeRoom, createFakeSubscription } from '../../../../../tests/mocks/data'; +import { createFakeSubscription } from '../../../../../tests/mocks/data'; -const mockRoom = createFakeRoom({ _id: 'room1', t: 'c', name: 'room1', fname: 'Room 1' }); -const mockSubscription = createFakeSubscription({ name: 'room1', t: 'c', disableNotifications: false, rid: 'room1' }); +const mockSubscription = createFakeSubscription({ name: 'room1', fname: 'Room 1', t: 'c', disableNotifications: false, rid: 'room1' }); jest.mock('../../../../../client/lib/rooms/roomCoordinator', () => ({ roomCoordinator: { @@ -42,7 +40,7 @@ describe('useRoomMenuActions', () => { it('should return all menu options for normal rooms', () => { const { result } = renderHook(() => useRoomMenuActions(mockHookProps), { wrapper: mockAppRoot() - .withSubscriptions([{ ...mockSubscription, rid: 'room1' }] as unknown as SubscriptionWithRoom[]) + .withSubscription({ ...mockSubscription, rid: 'room1' }) .withPermission('leave-c') .withPermission('leave-p') .withSetting('Favorite_Rooms', true) @@ -59,7 +57,7 @@ describe('useRoomMenuActions', () => { it('should return priorities section for omnichannel room', () => { const { result } = renderHook(() => useRoomMenuActions({ ...mockHookProps, type: 'l' }), { wrapper: mockAppRoot() - .withSubscriptions([{ ...mockSubscription, ...mockRoom, t: 'l' }] as unknown as SubscriptionWithRoom[]) + .withSubscription({ ...mockSubscription, t: 'l' }) .withPermission('leave-c') .withPermission('leave-p') .withSetting('Favorite_Rooms', true) @@ -76,7 +74,7 @@ describe('useRoomMenuActions', () => { it('should not return any menu option if hideDefaultOptions', () => { const { result } = renderHook(() => useRoomMenuActions({ ...mockHookProps, hideDefaultOptions: true }), { wrapper: mockAppRoot() - .withSubscriptions([{ ...mockSubscription, ...mockRoom }] as unknown as SubscriptionWithRoom[]) + .withSubscription(mockSubscription) .withPermission('leave-c') .withPermission('leave-p') .withSetting('Favorite_Rooms', true) @@ -89,7 +87,7 @@ describe('useRoomMenuActions', () => { it('should not return favorite room option if setting is disabled', () => { const { result } = renderHook(() => useRoomMenuActions(mockHookProps), { wrapper: mockAppRoot() - .withSubscriptions([{ ...mockSubscription, ...mockRoom }] as unknown as SubscriptionWithRoom[]) + .withSubscription(mockSubscription) .withPermission('leave-c') .withPermission('leave-p') .withSetting('Favorite_Rooms', false) diff --git a/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomLeave.spec.ts b/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomLeave.spec.ts index e864998f63781..24da19244b2fc 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomLeave.spec.ts +++ b/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomLeave.spec.ts @@ -1,5 +1,4 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; -import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { renderHook } from '@testing-library/react'; import { useRoomLeave } from './useRoomLeave'; @@ -25,7 +24,7 @@ jest.mock('../../../../../../../client/lib/rooms/roomCoordinator', () => ({ it('should return leave function if user has subscription', () => { const wrapper = mockAppRoot() .withPermission('leave-c') - .withSubscriptions([{ ...mockSubscription, rid: 'room1' }] as unknown as SubscriptionWithRoom[]) + .withSubscription({ ...mockSubscription, rid: 'room1' }) .build(); const { result } = renderHook(() => useRoomLeave(mockRoom), { wrapper }); diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.spec.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.spec.tsx new file mode 100644 index 0000000000000..1d82a96ff3e4b --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.spec.tsx @@ -0,0 +1,145 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { useMediaCallContext } from '@rocket.chat/ui-voip'; +import { act, renderHook } from '@testing-library/react'; + +import { useUserMediaCallAction } from './useUserMediaCallAction'; +import { createFakeRoom, createFakeSubscription, createFakeUser } from '../../../../../../tests/mocks/data'; + +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useUserAvatarPath: jest.fn().mockReturnValue((_args: any) => 'avatar-url'), +})); + +jest.mock('@rocket.chat/ui-voip', () => ({ + ...jest.requireActual('@rocket.chat/ui-voip'), + useMediaCallContext: jest.fn().mockImplementation(() => ({ + state: 'closed', + onToggleWidget: jest.fn(), + })), +})); + +jest.mock('../../../contexts/UserCardContext', () => ({ + useUserCard: jest.fn().mockReturnValue({ closeUserCard: jest.fn() }), +})); + +const useMediaCallContextMocked = jest.mocked(useMediaCallContext); + +describe('useUserMediaCallAction', () => { + const fakeUser = createFakeUser({ _id: 'own-uid' }); + const mockRid = 'room-id'; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return undefined if room is federated', () => { + const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), { + wrapper: mockAppRoot() + .withJohnDoe() + .withRoom(createFakeRoom({ federated: true })) + .build(), + }); + + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if state is unauthorized', () => { + useMediaCallContextMocked.mockReturnValueOnce({ + state: 'unauthorized', + onToggleWidget: undefined, + onEndCall: undefined, + peerInfo: undefined, + }); + + const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), { wrapper: mockAppRoot().build() }); + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if subscription is blocked', () => { + const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), { + wrapper: mockAppRoot() + .withJohnDoe() + .withRoom(createFakeRoom()) + .withSubscription(createFakeSubscription({ blocker: false, blocked: true })) + .build(), + }); + + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if subscription is blocker', () => { + const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), { + wrapper: mockAppRoot() + .withJohnDoe() + .withRoom(createFakeRoom()) + .withSubscription(createFakeSubscription({ blocker: true, blocked: false })) + .build(), + }); + + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if user is own user', () => { + const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), { + wrapper: mockAppRoot().withUser(fakeUser).withRoom(createFakeRoom()).withSubscription(createFakeSubscription()).build(), + }); + + expect(result.current).toBeUndefined(); + }); + + it('should return action if conditions are met', () => { + const fakeUser = createFakeUser(); + const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), { + wrapper: mockAppRoot() + .withJohnDoe() + .withRoom(createFakeRoom()) + .withSubscription(createFakeSubscription()) + .withTranslations('en', 'core', { + Voice_call__user_: 'Voice call {{user}}', + }) + .build(), + }); + + expect(result.current).toEqual( + expect.objectContaining({ + type: 'communication', + icon: 'phone', + title: `Voice call ${fakeUser.name}`, + disabled: false, + }), + ); + }); + + it('should call onClick handler correctly', () => { + const mockOnToggleWidget = jest.fn(); + useMediaCallContextMocked.mockReturnValueOnce({ + state: 'closed', + onToggleWidget: mockOnToggleWidget, + peerInfo: undefined, + onEndCall: () => undefined, + }); + + const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid)); + + act(() => result.current?.onClick()); + + expect(mockOnToggleWidget).toHaveBeenCalledWith({ + userId: fakeUser._id, + displayName: fakeUser.name, + avatarUrl: 'avatar-url', + }); + }); + + it('should be disabled if state is not closed, new, or unlicensed', () => { + useMediaCallContextMocked.mockReturnValueOnce({ + state: 'calling', + onToggleWidget: jest.fn(), + peerInfo: undefined, + onEndCall: () => undefined, + }); + + const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid)); + + expect(result.current?.disabled).toBe(true); + }); +}); diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.ts index fbde59d142f99..19c83c2a6ec86 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.ts @@ -1,5 +1,5 @@ -import type { IRoom, IUser } from '@rocket.chat/core-typings'; -import { useUserAvatarPath, useUserId, useUserSubscription } from '@rocket.chat/ui-contexts'; +import { isRoomFederated, type IRoom, type IUser } from '@rocket.chat/core-typings'; +import { useUserAvatarPath, useUserId, useUserRoom, useUserSubscription } from '@rocket.chat/ui-contexts'; import { useMediaCallContext } from '@rocket.chat/ui-voip'; import { useTranslation } from 'react-i18next'; @@ -14,9 +14,14 @@ export const useUserMediaCallAction = (user: Pick { const room = createFakeRoom(roomOverrides); - const subscription = faker.datatype.boolean() ? createFakeSubscription(subscriptionOverrides) : undefined; + const subscription = createFakeSubscription(subscriptionOverrides); return { rid: room._id, diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index 4058b91a58e3e..2f4d0c9f0735e 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -3,7 +3,6 @@ import type { DirectCallData, IRoom, ISetting, - ISubscription, IUser, ProviderCapabilities, Serialized, @@ -149,8 +148,11 @@ export class MockedAppRootBuilder { onLogout: () => () => undefined, queryPreference: () => [() => () => undefined, () => undefined], queryRoom: () => [() => () => undefined, () => this.room], - querySubscription: () => [() => () => undefined, () => this.subscriptions as unknown as ISubscription], - querySubscriptions: () => [() => () => undefined, () => this.subscriptions ?? []], // apply query and option + querySubscription: () => [() => () => undefined, () => this.subscription], + querySubscriptions: () => [ + () => () => undefined, + () => (this.subscription ? [this.subscription, ...(this.subscriptions ?? [])] : (this.subscriptions ?? [])), + ], // apply query and option user: null, userId: undefined, }; @@ -205,6 +207,8 @@ export class MockedAppRootBuilder { private subscriptions: SubscriptionWithRoom[] | undefined = undefined; + private subscription: SubscriptionWithRoom | undefined = undefined; + private modal: ModalContextValue = { currentModal: { component: null }, modal: { @@ -451,6 +455,12 @@ export class MockedAppRootBuilder { return this; } + withSubscription(subscription: SubscriptionWithRoom): this { + this.subscription = subscription; + + return this; + } + withRoom(room: IRoom): this { this.room = room;