Skip to content
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
d1654c3
chore: Remove "getPeerInfo" from "MediaCallContext"
gabriellsh Feb 18, 2026
229a314
refactor: Split `useMediaSession` into `useMediaSession` and `useMedi…
gabriellsh Feb 18, 2026
10e2553
refactor: Extract the `useMediaSessionControls` hook into it's own file.
gabriellsh Feb 18, 2026
d6e558e
chore: Rename `useMediaSession` return from `state` to `sessionState`
gabriellsh Feb 18, 2026
8c942f5
refactor: Provide all state information in a single object `sessionSt…
gabriellsh Feb 18, 2026
bfb16f9
refactor: Update user status through reducer dispatch
gabriellsh Feb 19, 2026
bee0936
fix: MediaCallContext external usage
gabriellsh Feb 19, 2026
45d311c
refactor: Split `MediaCallContext` into `MediaCallWidgetContext` and …
gabriellsh Feb 19, 2026
b42506a
chore: Update widget components to use new structure
gabriellsh Feb 19, 2026
be8b615
chore: update History components to use new structure
gabriellsh Feb 19, 2026
0ab2d15
chore: update usePeerAutocomplete tests
gabriellsh Feb 19, 2026
b3606fe
chore: use new structure inside MediaCall Providers
gabriellsh Feb 19, 2026
d10c927
chore: Update core components to use new structure
gabriellsh Feb 19, 2026
be20ca0
fix: useUserMediaCallAction.spec.tsx
gabriellsh Feb 19, 2026
4a04583
refactor: Extract `getAutocompleteOptions` from `MediaCallProvider` t…
gabriellsh Feb 23, 2026
4521add
refactor: Stricter types for `useMediaSession` reducer
gabriellsh Feb 23, 2026
2e47def
fix: useMediaStream hook stream state type
gabriellsh Feb 24, 2026
b73210e
refactor: Rename `MediaCallWidget(Context | Provder)`to `MediaCallVie…
gabriellsh Feb 25, 2026
6a91555
refactor: Reorganize contexts, providers, hooks and shared functions
gabriellsh Feb 25, 2026
3353139
refactor: replace `useState` with `useSyncExternalStore` on `usePeek…
gabriellsh Feb 26, 2026
f31265d
test: Unit tests for `deriveWidgetStateFromCallState` and `derivePeer…
gabriellsh Feb 26, 2026
4db22e7
test: Implement unit tests for `usePeekMediaSession(PeerInfo | State)`
gabriellsh Feb 26, 2026
6faee99
fix: `usePeekMediaSessionPeerInfo` getSnapshot function return lackin…
gabriellsh Feb 26, 2026
affb12c
test: Implement unit tests for `useMediaCallAction.spec.tsx`
gabriellsh Feb 26, 2026
8854ea7
fix: `useMediaCallOpenRoomTracker` remove unnecessary early return
gabriellsh Feb 26, 2026
b9e7596
test: Implement unit tests for `useMediaCallOpenRoomTracker`
gabriellsh Feb 26, 2026
4098810
test: Add snapshot and a11y tests for missing stories
gabriellsh Feb 26, 2026
ad651df
chore: Remove unused `useDevicePermissionPrompt` hook and update tests
gabriellsh Feb 26, 2026
899c22f
test: Add snapshot testing for `MediaCallHistoryTable`
gabriellsh Feb 26, 2026
0663c6e
Merge branch 'develop' into refactor/mediaCallClient
gabriellsh Feb 26, 2026
1ef5ee4
Apply suggestion from @coderabbitai[bot]
tassoevan Feb 26, 2026
642d3a2
fix: Small reviews
gabriellsh Feb 27, 2026
aa19a84
chore: Rename `useMediaCallViewContext` to `useMediaCallView`
gabriellsh Feb 27, 2026
d6766e3
Rename `useMediaCallInstanceContext` to `useMediaCallInstance`
gabriellsh Feb 27, 2026
03e23a5
Merge branch 'develop' into refactor/mediaCallClient
gabriellsh Mar 2, 2026
316683d
Merge branch 'develop' into refactor/mediaCallClient
gabriellsh Mar 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions apps/meteor/client/providers/MediaCallProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Emitter } from '@rocket.chat/emitter';
import { usePermission } from '@rocket.chat/ui-contexts';
import { MediaCallProvider as MediaCallProviderBase, MediaCallContext } from '@rocket.chat/ui-voip';
import { MediaCallProvider as MediaCallProviderBase, MediaCallInstanceContext } from '@rocket.chat/ui-voip';
import type { ReactNode } from 'react';
import { useMemo } from 'react';

Expand All @@ -13,17 +14,18 @@ const MediaCallProvider = ({ children }: { children: ReactNode }) => {

const unauthorizedContextValue = useMemo(
() => ({
state: 'unauthorized' as const,
onToggleWidget: undefined,
onEndCall: undefined,
peerInfo: undefined,
setOpenRoomId: undefined,
instance: undefined,
signalEmitter: new Emitter<any>(),
audioElement: undefined,
openRoomId: undefined,
setOpenRoomId: () => undefined,
getAutocompleteOptions: () => Promise.resolve([]),
}),
[],
);

if (!hasModule || (!canMakeInternalCall && !canMakeExternalCall)) {
return <MediaCallContext.Provider value={unauthorizedContextValue}>{children}</MediaCallContext.Provider>;
return <MediaCallInstanceContext.Provider value={unauthorizedContextValue}>{children}</MediaCallInstanceContext.Provider>;
}

return <MediaCallProviderBase>{children}</MediaCallProviderBase>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { GenericMenu } from '@rocket.chat/ui-client';
import type { CallHistoryTableExternalContact, CallHistoryTableRowProps } from '@rocket.chat/ui-voip';
import { CallHistoryTableRow, useMediaCallContext, isCallingBlocked } from '@rocket.chat/ui-voip';
import { CallHistoryTableRow, usePeekMediaSessionState, useWidgetExternalControls } from '@rocket.chat/ui-voip';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

Expand All @@ -11,27 +11,29 @@ type CallHistoryRowExternalUserProps = Omit<CallHistoryTableRowProps<CallHistory
const CallHistoryRowExternalUser = ({ _id, contact, type, status, duration, timestamp, onClick }: CallHistoryRowExternalUserProps) => {
const { t } = useTranslation();

const { onToggleWidget, state } = useMediaCallContext();
const state = usePeekMediaSessionState();
const { toggleWidget } = useWidgetExternalControls();

const handleClick = useCallback(() => {
onClick(_id);
}, [onClick, _id]);

const actions = useMemo(() => {
if (state === 'unauthorized' || state === 'unlicensed' || !onToggleWidget) {
if (state === 'unavailable') {
return [];
}
const disabled = state !== 'available';
return [
{
id: 'voiceCall',
icon: 'phone',
content: t('Voice_call'),
disabled: isCallingBlocked(state),
tooltip: isCallingBlocked(state) ? t('Call_in_progress') : undefined,
onClick: () => onToggleWidget({ number: contact.number }),
disabled,
tooltip: disabled ? t('Call_in_progress') : undefined,
onClick: () => toggleWidget({ number: contact.number }),
} as const,
];
}, [contact, onToggleWidget, t, state]);
}, [contact, toggleWidget, t, state]);

return (
<CallHistoryTableRow
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Keys as IconName } from '@rocket.chat/icons';
import { GenericMenu } from '@rocket.chat/ui-client';
import type { CallHistoryTableRowProps, CallHistoryTableInternalContact, MediaCallState } from '@rocket.chat/ui-voip';
import { CallHistoryTableRow, useMediaCallContext, isCallingBlocked } from '@rocket.chat/ui-voip';
import { CallHistoryTableRow, usePeekMediaSessionState } from '@rocket.chat/ui-voip';
import type { CallHistoryTableRowProps, CallHistoryTableInternalContact, PeekMediaSessionStateReturn } from '@rocket.chat/ui-voip';
import type { TFunction } from 'i18next';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -37,11 +37,11 @@ const i18nDictionary: Record<HistoryActions, string> = {
userInfo: 'User_info',
} as const;

const getItems = (actions: HistoryActionCallbacks, t: TFunction, state: MediaCallState) => {
const getItems = (actions: HistoryActionCallbacks, t: TFunction, state: PeekMediaSessionStateReturn) => {
return (Object.entries(actions) as [HistoryActions, () => void][])
.filter(([_, callback]) => callback)
.map(([action, callback]) => {
const disabled = action === 'voiceCall' && isCallingBlocked(state);
const disabled = action === 'voiceCall' && state !== 'available';
return {
id: action,
icon: iconDictionary[action],
Expand All @@ -66,7 +66,7 @@ const CallHistoryRowInternalUser = ({
onClick,
}: CallHistoryRowInternalUserProps) => {
const { t } = useTranslation();
const { state } = useMediaCallContext();
const state = usePeekMediaSessionState();
const actions = useMediaCallInternalHistoryActions({
contact: {
_id: contact._id,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CallHistoryItem, IExternalMediaCallHistoryItem, IMediaCall, Serialized } from '@rocket.chat/core-typings';
import { CallHistoryContextualBar, useMediaCallContext } from '@rocket.chat/ui-voip';
import { CallHistoryContextualBar, useWidgetExternalControls, usePeekMediaSessionState } from '@rocket.chat/ui-voip';
import { useMemo } from 'react';

type ExternalCallEndpointData = Serialized<{
Expand Down Expand Up @@ -33,16 +33,17 @@ const MediaCallHistoryExternal = ({ data, onClose }: MediaCallHistoryExternalPro
state: data.item.state,
};
}, [data]);
const { onToggleWidget } = useMediaCallContext();
const state = usePeekMediaSessionState();
const { toggleWidget } = useWidgetExternalControls();

const actions = useMemo(() => {
if (!onToggleWidget) {
if (state !== 'available') {
return {};
}
return {
voiceCall: () => onToggleWidget(contact),
voiceCall: () => toggleWidget(contact),
};
}, [contact, onToggleWidget]);
}, [contact, state, toggleWidget]);

return <CallHistoryContextualBar onClose={onClose} actions={actions} contact={contact} data={historyData} />;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import { useGoToDirectMessage } from '@rocket.chat/ui-client';
import { useRouter, useUserAvatarPath } from '@rocket.chat/ui-contexts';
import { useMediaCallContext } from '@rocket.chat/ui-voip';
import { useWidgetExternalControls, usePeekMediaSessionState } from '@rocket.chat/ui-voip';
import { useMemo } from 'react';

export type InternalCallHistoryContact = {
Expand All @@ -28,17 +28,18 @@ export const useMediaCallInternalHistoryActions = ({
messageRoomId,
openUserInfo,
}: UseMediaCallInternalHistoryActionsBaseOptions) => {
const { onToggleWidget, state } = useMediaCallContext();
const state = usePeekMediaSessionState();
const { toggleWidget } = useWidgetExternalControls();
const router = useRouter();

const getAvatarUrl = useUserAvatarPath();

const voiceCall = useEffectEvent(() => {
if (state === 'unauthorized' || state === 'unlicensed' || !onToggleWidget) {
if (state !== 'available') {
return;
}

onToggleWidget({
toggleWidget({
userId: contact._id,
displayName: contact.displayName ?? '',
username: contact.username,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
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';

const usePeekMediaSessionStateMock = jest.fn().mockReturnValue('available');
const toggleWidgetMock = jest.fn();

jest.mock('@rocket.chat/ui-contexts', () => ({
...jest.requireActual('@rocket.chat/ui-contexts'),
useUserAvatarPath: jest.fn().mockReturnValue((_args: any) => 'avatar-url'),
Expand All @@ -13,14 +15,10 @@ jest.mock('@rocket.chat/ui-contexts', () => ({

jest.mock('@rocket.chat/ui-voip', () => ({
...jest.requireActual('@rocket.chat/ui-voip'),
useMediaCallContext: jest.fn().mockImplementation(() => ({
state: 'closed',
onToggleWidget: jest.fn(),
})),
useWidgetExternalControls: jest.fn().mockReturnValue({ toggleWidget: (...args: any[]) => toggleWidgetMock(...args) }),
usePeekMediaSessionState: () => usePeekMediaSessionStateMock(),
}));

const useMediaCallContextMocked = jest.mocked(useMediaCallContext);

describe('useUserMediaCallAction', () => {
const fakeUser = createFakeUser({ _id: 'own-uid' });
const mockRid = 'room-id';
Expand All @@ -29,6 +27,10 @@ describe('useUserMediaCallAction', () => {
jest.clearAllMocks();
});

beforeEach(() => {
usePeekMediaSessionStateMock.mockReturnValue('available');
});

it('should return undefined if room is federated', () => {
const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), {
wrapper: mockAppRoot()
Expand All @@ -41,13 +43,7 @@ describe('useUserMediaCallAction', () => {
});

it('should return undefined if state is unauthorized', () => {
useMediaCallContextMocked.mockReturnValueOnce({
state: 'unauthorized',
onToggleWidget: undefined,
onEndCall: undefined,
peerInfo: undefined,
setOpenRoomId: undefined,
});
usePeekMediaSessionStateMock.mockReturnValueOnce('unavailable');

const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), { wrapper: mockAppRoot().build() });
expect(result.current).toBeUndefined();
Expand Down Expand Up @@ -109,34 +105,21 @@ describe('useUserMediaCallAction', () => {
});

it('should call onClick handler correctly', () => {
const mockOnToggleWidget = jest.fn();
useMediaCallContextMocked.mockReturnValueOnce({
state: 'closed',
onToggleWidget: mockOnToggleWidget,
peerInfo: undefined,
onEndCall: () => undefined,
setOpenRoomId: () => undefined,
});
usePeekMediaSessionStateMock.mockReturnValueOnce('available');

const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid));

act(() => result.current?.onClick());

expect(mockOnToggleWidget).toHaveBeenCalledWith({
expect(toggleWidgetMock).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,
setOpenRoomId: () => undefined,
});
usePeekMediaSessionStateMock.mockReturnValueOnce('calling');

const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid));

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { useUserAvatarPath, useUserId, useUserSubscription, useUserCard, useUserRoom } from '@rocket.chat/ui-contexts';
import { useMediaCallContext } from '@rocket.chat/ui-voip';
import { usePeekMediaSessionState, useWidgetExternalControls } from '@rocket.chat/ui-voip';
import { useTranslation } from 'react-i18next';

import type { UserInfoAction } from '../useUserInfoActions';
Expand All @@ -10,7 +10,8 @@ export const useUserMediaCallAction = (user: Pick<IUser, '_id' | 'username' | 'n
const { t } = useTranslation();
const ownUserId = useUserId();
const { closeUserCard } = useUserCard();
const { state, onToggleWidget } = useMediaCallContext();
const state = usePeekMediaSessionState();
const { toggleWidget } = useWidgetExternalControls();
const getAvatarUrl = useUserAvatarPath();

const currentSubscription = useUserSubscription(rid);
Expand All @@ -22,15 +23,15 @@ export const useUserMediaCallAction = (user: Pick<IUser, '_id' | 'username' | 'n
return undefined;
}

if (state === 'unauthorized') {
if (state === 'unavailable') {
return undefined;
}

if (blocked) {
return undefined;
}

const disabled = !['closed', 'new', 'unlicensed'].includes(state);
const disabled = state !== 'available';

if (user._id === ownUserId) {
return undefined;
Expand All @@ -44,7 +45,7 @@ export const useUserMediaCallAction = (user: Pick<IUser, '_id' | 'username' | 'n
icon: 'phone',
onClick: () => {
closeUserCard();
onToggleWidget({
toggleWidget({
userId: user._id,
displayName: user.name || user.username || '',
avatarUrl,
Expand Down
4 changes: 2 additions & 2 deletions packages/ui-voip/src/components/DevicePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { forwardRef, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { ActionButton } from '.';
import { useMediaCallContext } from '../context';
import { useMediaCallView } from '../context/MediaCallViewContext';
import { useDevicePermissionPrompt2, stopTracks } from '../hooks/useDevicePermissionPrompt';

type DevicePickerButtonProps = {
Expand Down Expand Up @@ -39,7 +39,7 @@ const getDefaultDeviceItem = (label: string, type: 'input' | 'output') => ({
const DevicePicker = ({ secondary = false }: { secondary?: boolean }) => {
const { t } = useTranslation();

const { onDeviceChange } = useMediaCallContext();
const { onDeviceChange } = useMediaCallView();

const availableDevices = useAvailableDevices();
const selectedAudioDevices = useSelectedDevices();
Expand Down
21 changes: 21 additions & 0 deletions packages/ui-voip/src/components/Keypad/Keypad.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';

import * as stories from './Keypad.stories';

const testCases = Object.values(composeStories(stories))
.filter((Story) => !Story.tags?.includes('skip'))
.map((Story) => [Story.storyName || 'Story', Story]);

test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />);
expect(view.baseElement).toMatchSnapshot();
});

test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);

const results = await axe(container);
expect(results).toHaveNoViolations();
});
4 changes: 3 additions & 1 deletion packages/ui-voip/src/components/Keypad/Keypad.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Meta, StoryFn } from '@storybook/react';

import Keypad from './Keypad';
import { useTonePlayer } from '../../context/useTonePlayer';
import { useTonePlayer } from '../../hooks/useTonePlayer';

export default {
title: 'V2/Components/Keypad',
Expand All @@ -13,3 +13,5 @@ export const KeypadStoryWithTone: StoryFn<typeof Keypad> = () => {
const playTone = useTonePlayer();
return <Keypad onKeyPress={(key) => playTone(key as any)} />;
};

KeypadStoryWithTone.tags = ['skip'];
Loading
Loading