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;