feat: Voice call device permission flow (#36397)

This commit is contained in:
gabriellsh 2025-08-22 10:26:17 -03:00 committed by GitHub
parent ae48139629
commit c6ef437d90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1352 additions and 124 deletions

View File

@ -0,0 +1,10 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/i18n": minor
"@rocket.chat/mock-providers": minor
"@rocket.chat/ui-client": minor
"@rocket.chat/ui-contexts": minor
"@rocket.chat/ui-voip": minor
---
Introduces a new flow for requesting device permissions for Voice Calling, prompting the user before the request. Also solves a few issues with the device selection menu.

View File

@ -1,23 +1,22 @@
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import { usePermission, useUserId } from '@rocket.chat/ui-contexts';
import { useVoipAPI, useVoipState } from '@rocket.chat/ui-voip';
import { useMediaDeviceMicrophonePermission, usePermission, useUserId } from '@rocket.chat/ui-contexts';
import { useVoipAPI, useVoipState, useDevicePermissionPrompt } from '@rocket.chat/ui-voip';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useMediaPermissions } from '../../views/room/composer/messageBox/hooks/useMediaPermissions';
import { useRoom } from '../../views/room/contexts/RoomContext';
import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext';
import { useUserInfoQuery } from '../useUserInfoQuery';
import { useVoipWarningModal } from '../useVoipWarningModal';
export const useVoiceCallRoomAction = () => {
const { t } = useTranslation();
const { uids = [] } = useRoom();
const ownUserId = useUserId();
const canStartVoiceCall = usePermission('view-user-voip-extension');
const dispatchWarning = useVoipWarningModal();
const [isMicPermissionDenied] = useMediaPermissions('microphone');
const { state: micPermissionState } = useMediaDeviceMicrophonePermission();
const isMicPermissionDenied = micPermissionState === 'denied';
const { isEnabled, isRegistered, isInCall } = useVoipState();
const { makeCall } = useVoipAPI();
@ -36,19 +35,26 @@ export const useVoiceCallRoomAction = () => {
const tooltip = useMemo(() => {
if (isMicPermissionDenied) {
return t('Microphone_access_not_allowed');
return 'Microphone_access_not_allowed';
}
if (isInCall) {
return t('Unable_to_make_calls_while_another_is_ongoing');
return 'Unable_to_make_calls_while_another_is_ongoing';
}
return disabled ? t('Voice_calling_disabled') : '';
}, [disabled, isInCall, isMicPermissionDenied, t]);
return disabled ? 'Voice_calling_disabled' : '';
}, [disabled, isInCall, isMicPermissionDenied]);
const promptPermission = useDevicePermissionPrompt({
actionType: 'outgoing',
onAccept: () => {
makeCall(remoteUser?.freeSwitchExtension as string);
},
});
const handleOnClick = useEffectEvent(() => {
if (canMakeVoipCall) {
return makeCall(remoteUser?.freeSwitchExtension as string);
return promptPermission();
}
dispatchWarning();
});
@ -60,14 +66,13 @@ export const useVoiceCallRoomAction = () => {
return {
id: 'start-voice-call',
title: 'Voice_Call',
title: tooltip || 'Voice_Call',
icon: 'phone',
featured: true,
action: handleOnClick,
groups: ['direct'] as const,
order: 2,
disabled,
tooltip,
};
}, [allowed, disabled, handleOnClick, tooltip]);
};

View File

@ -1,6 +1,7 @@
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import type { Device, DeviceContextValue } from '@rocket.chat/ui-contexts';
import { DeviceContext } from '@rocket.chat/ui-contexts';
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import type { ReactElement, ReactNode } from 'react';
import { useEffect, useState, useMemo } from 'react';
@ -10,20 +11,27 @@ type DeviceProviderProps = {
children?: ReactNode | undefined;
};
const defaultDevices = {
audioInput: [],
audioOutput: [],
defaultAudioOutputDevice: {
id: '',
label: '',
type: 'audiooutput',
},
defaultAudioInputDevice: {
id: '',
label: '',
type: 'audioinput',
},
};
const devicesQueryKey = ['media-devices-list'];
export const DeviceProvider = ({ children }: DeviceProviderProps): ReactElement => {
const [enabled] = useState(typeof isSecureContext && isSecureContext);
const [availableAudioOutputDevices, setAvailableAudioOutputDevices] = useState<Device[]>([]);
const [availableAudioInputDevices, setAvailableAudioInputDevices] = useState<Device[]>([]);
const [selectedAudioOutputDevice, setSelectedAudioOutputDevice] = useState<Device>({
id: 'default',
label: '',
type: 'audio',
});
const [selectedAudioInputDevice, setSelectedAudioInputDevice] = useState<Device>({
id: 'default',
label: '',
type: 'audio',
});
const [selectedAudioOutputDevice, setSelectedAudioOutputDevice] = useState<Device | undefined>(undefined);
const [selectedAudioInputDevice, setSelectedAudioInputDevice] = useState<Device | undefined>(undefined);
const setAudioInputDevice = (device: Device): void => {
if (!isSecureContext) {
@ -45,38 +53,88 @@ export const DeviceProvider = ({ children }: DeviceProviderProps): ReactElement
},
);
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: devicesQueryKey,
enabled,
queryFn: async () => {
const devices = await navigator.mediaDevices?.enumerateDevices();
if (!devices || devices.length === 0) {
return defaultDevices;
}
const mappedDevices: Device[] = devices.map((device) => ({
id: device.deviceId,
label: device.label,
type: device.kind,
}));
const audioInput = mappedDevices.filter((device) => device.type === 'audioinput');
const audioOutput = mappedDevices.filter((device) => device.type === 'audiooutput');
return {
audioInput,
audioOutput,
defaultAudioOutputDevice: audioOutput[0],
defaultAudioInputDevice: audioInput[0],
};
},
initialData: defaultDevices,
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: true,
staleTime: 0,
});
const { data: permissionStatus } = useQuery({
queryKey: [...devicesQueryKey, 'permission-status'],
queryFn: async () => {
if (!navigator.permissions) {
return;
}
const result = await navigator.permissions.query({ name: 'microphone' as PermissionName });
return result;
},
initialData: undefined,
placeholderData: undefined,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: true,
});
useEffect(() => {
if (!enabled) {
if (!permissionStatus) {
return;
}
const setMediaDevices = (): void => {
navigator.mediaDevices?.enumerateDevices().then((devices) => {
const audioInput: Device[] = [];
const audioOutput: Device[] = [];
devices.forEach((device) => {
const mediaDevice: Device = {
id: device.deviceId,
label: device.label,
type: device.kind,
};
if (device.kind === 'audioinput') {
audioInput.push(mediaDevice);
} else if (device.kind === 'audiooutput') {
audioOutput.push(mediaDevice);
}
});
setAvailableAudioOutputDevices(audioOutput);
setAvailableAudioInputDevices(audioInput);
});
const invalidateQueries = (): void => {
queryClient.invalidateQueries({ queryKey: devicesQueryKey });
};
navigator.mediaDevices?.addEventListener('devicechange', setMediaDevices);
setMediaDevices();
permissionStatus.addEventListener('change', invalidateQueries);
return (): void => {
navigator.mediaDevices?.removeEventListener('devicechange', setMediaDevices);
permissionStatus.removeEventListener('change', invalidateQueries);
};
}, [enabled]);
}, [permissionStatus, queryClient]);
useEffect(() => {
if (!enabled || !navigator.mediaDevices) {
return;
}
const invalidateQuery = (): void => {
queryClient.invalidateQueries({ queryKey: devicesQueryKey, exact: true });
};
navigator.mediaDevices.addEventListener('devicechange', invalidateQuery);
return (): void => {
navigator.mediaDevices.removeEventListener('devicechange', invalidateQuery);
};
}, [enabled, queryClient]);
const contextValue = useMemo((): DeviceContextValue => {
if (!enabled) {
@ -84,23 +142,19 @@ export const DeviceProvider = ({ children }: DeviceProviderProps): ReactElement
enabled,
};
}
const { audioInput, audioOutput, defaultAudioOutputDevice, defaultAudioInputDevice } = data;
return {
enabled,
availableAudioOutputDevices,
availableAudioInputDevices,
selectedAudioOutputDevice,
selectedAudioInputDevice,
permissionStatus,
availableAudioOutputDevices: audioOutput,
availableAudioInputDevices: audioInput,
selectedAudioOutputDevice: selectedAudioOutputDevice || defaultAudioOutputDevice,
selectedAudioInputDevice: selectedAudioInputDevice || defaultAudioInputDevice,
setAudioOutputDevice,
setAudioInputDevice,
};
}, [
availableAudioInputDevices,
availableAudioOutputDevices,
enabled,
selectedAudioInputDevice,
selectedAudioOutputDevice,
setAudioOutputDevice,
]);
}, [enabled, data, permissionStatus, selectedAudioOutputDevice, selectedAudioInputDevice, setAudioOutputDevice]);
return <DeviceContext.Provider value={contextValue}>{children}</DeviceContext.Provider>;
};

View File

@ -5635,6 +5635,11 @@
"VoIP_TeamCollab_Ice_Servers_Description": "A list of Ice Servers (STUN and/or TURN), separated by comma. \n Username, password and port are allowed in the format `username:password@stun:host:port` or `username:password@turn:host:port`. \n Both username and password may be html-encoded.",
"VoIP_Toggle": "Enable/Disable VoIP",
"VoIP_available_setup_freeswitch_server_details": "VoIP is available but the FreeSwitch server details need to be set up from the team voice call settings.",
"VoIP_device_permission_required": "Mic/speaker access required",
"VoIP_device_permission_required_description": "Your web browser stopped {{workspaceUrl}} from using your microphone and/or speaker.\n\nAllow speaker and microphone access in your browser settings to prevent seeing this message again.",
"VoIP_allow_and_call": "Allow and call",
"VoIP_allow_and_accept": "Allow and accept",
"VoIP_cancel_and_reject": "Cancel and reject",
"Voice_Call": "Voice Call",
"Voice_Call_Extension": "Voice Call Extension",
"Voice_and_omnichannel": "Voice and omnichannel",

View File

@ -16,6 +16,7 @@ import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter';
import type { Method, OperationParams, OperationResult, PathPattern, UrlParams } from '@rocket.chat/rest-typings';
import type {
Device,
DeviceContext,
LoginService,
ModalContextValue,
ServerContextValue,
@ -213,9 +214,16 @@ export class MockedAppRootBuilder {
private events = new Emitter<MockedAppRootEvents>();
private audioInputDevices: Device[] = [];
private audioOutputDevices: Device[] = [];
private deviceContext: Partial<ContextType<typeof DeviceContext>> = {
enabled: true,
availableAudioOutputDevices: [],
availableAudioInputDevices: [],
selectedAudioOutputDevice: undefined,
selectedAudioInputDevice: undefined,
setAudioOutputDevice: () => undefined,
setAudioInputDevice: () => undefined,
permissionStatus: undefined,
};
wrap(wrapper: (children: ReactNode) => ReactNode): this {
this.wrappers.push(wrapper);
@ -489,12 +497,29 @@ export class MockedAppRootBuilder {
}
withAudioInputDevices(devices: Device[]): this {
this.audioInputDevices = devices;
if (!this.deviceContext.enabled) {
throw new Error('DeviceContext is not enabled');
}
this.deviceContext.availableAudioInputDevices = devices;
return this;
}
withAudioOutputDevices(devices: Device[]): this {
this.audioOutputDevices = devices;
if (!this.deviceContext.enabled) {
throw new Error('DeviceContext is not enabled');
}
this.deviceContext.availableAudioOutputDevices = devices;
return this;
}
withMicrophonePermissionState(status: PermissionStatus): this {
if (!this.deviceContext.enabled) {
throw new Error('DeviceContext is not enabled');
}
this.deviceContext.permissionStatus = status;
return this;
}
@ -542,20 +567,7 @@ export class MockedAppRootBuilder {
},
});
const {
server,
router,
settings,
user,
userPresence,
videoConf,
i18n,
authorization,
wrappers,
audioInputDevices,
audioOutputDevices,
authentication,
} = this;
const { server, router, settings, user, userPresence, videoConf, i18n, authorization, wrappers, deviceContext, authentication } = this;
const reduceTranslation = (translation?: ContextType<typeof TranslationContext>): ContextType<typeof TranslationContext> => {
return {
@ -630,10 +642,7 @@ export class MockedAppRootBuilder {
<CustomSoundProvider> */}
<UserContext.Provider value={user}>
<AuthenticationContext.Provider value={authentication}>
<MockedDeviceContext
availableAudioInputDevices={audioInputDevices}
availableAudioOutputDevices={audioOutputDevices}
>
<MockedDeviceContext {...deviceContext}>
<ModalContext.Provider value={modal}>
<AuthorizationContext.Provider value={authorization}>
{/* <EmojiPickerProvider>

View File

@ -2,12 +2,22 @@ import type { DeviceContextValue } from '@rocket.chat/ui-contexts';
import { DeviceContext } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
const mockPermissionStatus: PermissionStatus = {
state: 'granted',
name: 'microphone',
onchange: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => true,
};
const mockDeviceContextValue: DeviceContextValue = {
enabled: true,
selectedAudioOutputDevice: undefined,
selectedAudioInputDevice: undefined,
availableAudioOutputDevices: [],
availableAudioInputDevices: [],
permissionStatus: mockPermissionStatus,
setAudioOutputDevice: () => undefined,
setAudioInputDevice: () => undefined,
};

View File

@ -11,6 +11,7 @@ type GenericMenuCommonProps = {
icon?: ComponentProps<typeof IconButton>['icon'];
disabled?: boolean;
callbackAction?: () => void;
isOpen?: boolean;
};
type GenericMenuConditionalProps =

View File

@ -0,0 +1,12 @@
import client from '@rocket.chat/jest-presets/client';
import type { Config } from 'jest';
export default {
preset: client.preset,
setupFilesAfterEnv: [...client.setupFilesAfterEnv],
moduleNameMapper: {
'^react($|/.+)': '<rootDir>/../../node_modules/react$1',
'^react-dom($|/.+)': '<rootDir>/../../node_modules/react-dom$1',
'^react-i18next($|/.+)': '<rootDir>/../../node_modules/react-i18next$1',
},
} satisfies Config;

View File

@ -9,13 +9,16 @@
"@rocket.chat/fuselage-hooks": "^0.37.0",
"@rocket.chat/fuselage-tokens": "~0.33.2",
"@rocket.chat/i18n": "workspace:~",
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/rest-typings": "workspace:^",
"@rocket.chat/tools": "workspace:~",
"@types/jest": "~30.0.0",
"@types/react": "~18.3.23",
"@types/react-dom": "~18.3.7",
"eslint": "~8.45.0",
"eslint-plugin-react-hooks": "^5.0.0",
"i18next": "~23.4.9",
"jest": "~30.0.2",
"mongodb": "6.10.0",
"react": "~18.3.1",
"typescript": "~5.9.2"
@ -39,7 +42,9 @@
"lint": "eslint --ext .js,.jsx,.ts,.tsx .",
"lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix",
"dev": "tsc --watch --preserveWatchOutput -p tsconfig.json",
"build": "rm -rf dist && tsc -p tsconfig.json"
"build": "rm -rf dist && tsc -p tsconfig.json",
"test": "jest",
"testunit": "jest"
},
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",

View File

@ -17,6 +17,7 @@ type EnabledDeviceContextValue = {
setAudioOutputDevice: (data: { outputDevice: Device; HTMLAudioElement: HTMLAudioElement }) => void;
setAudioInputDevice: (device: Device) => void;
// setVideoInputDevice: (device: Device) => void;
permissionStatus: PermissionStatus | undefined;
};
type DisabledDeviceContextValue = {

View File

@ -0,0 +1,68 @@
import { renderHook } from '@testing-library/react';
import { useMediaDeviceMicrophonePermission } from './useMediaDevicePermission';
import { DeviceContext } from '../DeviceContext';
const states = [
{ expectedState: 'granted', state: 'granted', requestDevice: 'function' },
{ expectedState: 'denied', state: 'denied', requestDevice: 'undefined' },
{ expectedState: 'prompt', state: 'prompt', requestDevice: 'function' },
];
const getWrapper =
(state: PermissionState | undefined, availableAudioInputDevices: any[] = [], enabled = true) =>
({ children }: { children: any }) => {
return (
<DeviceContext.Provider
value={{
enabled,
selectedAudioOutputDevice: undefined,
selectedAudioInputDevice: undefined,
availableAudioOutputDevices: [],
availableAudioInputDevices,
permissionStatus: state ? ({ state } as PermissionStatus) : undefined,
setAudioOutputDevice: () => undefined,
setAudioInputDevice: () => undefined,
}}
>
{children}
</DeviceContext.Provider>
);
};
describe('useMediaDeviceMicrophonePermission', () => {
it('Should return permission state denied and requestDevice is undefined if context is disabled', async () => {
const { result } = renderHook(() => useMediaDeviceMicrophonePermission(), {
wrapper: getWrapper(undefined, ['device1', 'device2'], false),
});
expect(result.current.state).toBe('denied');
expect(result.current.requestDevice).toBeUndefined();
});
it.each(states)('Should return permission state $state and requestDevice is $requestDevice', async ({ state, requestDevice }) => {
const { result } = renderHook(() => useMediaDeviceMicrophonePermission(), {
wrapper: getWrapper(state as PermissionState),
});
expect(result.current.state).toBe(state);
expect(typeof result.current.requestDevice).toBe(requestDevice);
});
it('Should return permission state granted and requestDevice is function if permissionStatus is undefined and availableAudioInputDevices has records', async () => {
const { result } = renderHook(() => useMediaDeviceMicrophonePermission(), {
wrapper: getWrapper(undefined, ['device1', 'device2']),
});
expect(result.current.state).toBe('granted');
expect(typeof result.current.requestDevice).toBe('function');
});
it('Should return permission state prompt and requestDevice is function if permissionStatus is undefined and availableAudioInputDevices is empty', async () => {
const { result } = renderHook(() => useMediaDeviceMicrophonePermission(), {
wrapper: getWrapper(undefined),
});
expect(result.current.state).toBe('prompt');
expect(typeof result.current.requestDevice).toBe('function');
});
});

View File

@ -0,0 +1,58 @@
import { useContext } from 'react';
import { DeviceContext, isDeviceContextEnabled } from '../DeviceContext';
export const requestDevice = async ({
onAccept,
onReject,
}: {
onAccept?: (stream: MediaStream) => void;
onReject?: (error: DOMException) => void;
}): Promise<void> => {
if (!navigator.mediaDevices) {
return;
}
navigator.mediaDevices.getUserMedia({ audio: true }).then(onAccept, onReject);
};
const isPermissionDenied = (state: PermissionState): state is 'denied' => {
return state === 'denied';
};
type DeniedReturn = { state: 'denied'; requestDevice?: never };
type PromptOrGrantedReturn = { state: 'prompt' | 'granted'; requestDevice: typeof requestDevice };
/**
* @description Hook to check if the microphone permission is granted. If the permission is denied, or the permission is not requested, the hook will return a function to request the permission. Right now just the microphone permission is handled with this hook, since DeviceContext is only used for audio input and output.
* @returns { state: 'granted' } if the permission is granted
* @returns { state: 'denied' } if the permission is denied
* @returns { state: 'prompt', requestPrompt: function ({onAccept, onReject}) {} } if the permission is in prompt state.
*/
export const useMediaDeviceMicrophonePermission = (): DeniedReturn | PromptOrGrantedReturn => {
const context = useContext(DeviceContext);
if (!isDeviceContextEnabled(context)) {
return {
state: 'denied',
};
}
const { permissionStatus, availableAudioInputDevices } = context;
if (permissionStatus) {
if (isPermissionDenied(permissionStatus.state)) {
return { state: permissionStatus.state };
}
return { state: permissionStatus.state, requestDevice };
}
if (availableAudioInputDevices.length > 0) {
return { state: 'granted', requestDevice };
}
return {
state: 'prompt',
requestDevice,
};
};

View File

@ -96,6 +96,7 @@ export { useAccountsCustomFields } from './hooks/useAccountsCustomFields';
export { useUserPresence } from './hooks/useUserPresence';
export { useUnstoreLoginToken } from './hooks/useUnstoreLoginToken';
export { useOnLogout } from './hooks/useOnLogout';
export { useMediaDeviceMicrophonePermission, type requestDevice } from './hooks/useMediaDevicePermission';
export { useWriteStream } from './hooks/useWriteStream';
export { UploadResult } from './ServerContext';

View File

@ -0,0 +1,19 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './PermissionFlowModal.stories';
const testCases = Object.values(composeStories(stories)).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();
});

View File

@ -0,0 +1,66 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryObj } from '@storybook/react';
import type { ReactElement } from 'react';
import PermissionFlowModal from './PermissionFlowModal';
const noop = () => undefined;
const meta = {
title: 'Components/Permission Flow',
component: PermissionFlowModal,
decorators: [
mockAppRoot()
.withTranslations('en', 'core', {
VoIP_device_permission_required: 'Mic/speaker access required',
VoIP_allow_and_call: 'Allow and call',
VoIP_allow_and_accept: 'Allow and accept',
VoIP_cancel_and_reject: 'Cancel and reject',
Cancel: 'Cancel',
VoIP_device_permission_required_description:
'Your web browser stopped {{workspaceUrl}} from using your microphone and/or speaker.\n\nAllow speaker and microphone access in your browser settings to prevent seeing this message again.',
})
.buildStoryDecorator(),
(Story): ReactElement => <Story />,
],
} satisfies Meta<typeof PermissionFlowModal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const PermissionFlowModalOutgoingPrompt: Story = {
args: {
onCancel: noop,
onConfirm: noop,
type: 'outgoingPrompt',
},
name: 'Outgoing call, permission in prompt state',
};
export const PermissionFlowModalIncomingPrompt: Story = {
args: {
onCancel: noop,
onConfirm: noop,
type: 'incomingPrompt',
},
name: 'Incoming call, permission in prompt state',
};
export const PermissionFlowModalDeviceChangePrompt: Story = {
args: {
onCancel: noop,
onConfirm: noop,
type: 'deviceChangePrompt',
},
name: 'Device change, permission in prompt state',
};
export const PermissionFlowModalDenied: Story = {
args: {
onCancel: noop,
onConfirm: noop,
type: 'denied',
},
name: 'Permission denied',
};

View File

@ -0,0 +1,107 @@
import { css } from '@rocket.chat/css-in-js';
import {
Box,
Button,
Modal,
ModalHeader,
ModalTitle,
ModalClose,
ModalContent,
ModalFooter,
ModalFooterControllers,
} from '@rocket.chat/fuselage';
import { useAbsoluteUrl, useSetModal } from '@rocket.chat/ui-contexts';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
export type PermissionFlowModalType = 'denied' | 'incomingPrompt' | 'outgoingPrompt' | 'deviceChangePrompt';
type PermissionFlowModalProps = {
onCancel: () => void;
onConfirm: () => void;
type: PermissionFlowModalType;
};
// MarkdownText is a bit overkill for this
// This css rules ensures that `\n` actually breaks lines.
const breakSpaces = css`
white-space: break-spaces;
`;
const getFooter = (
type: PermissionFlowModalProps['type'],
{
onCancel,
onConfirm,
onClose,
t,
}: { onCancel: () => void; onConfirm: () => void; onClose: () => void; t: ReturnType<typeof useTranslation>['t'] },
) => {
switch (type) {
case 'denied':
return [
<Button key='cancel' onClick={onClose}>
{t('Cancel')}
</Button>,
];
case 'incomingPrompt':
return [
<Button key='cancel' danger onClick={onCancel} icon='phone-off'>
{t('VoIP_cancel_and_reject')}
</Button>,
<Button key='confirm' success onClick={onConfirm} icon='phone'>
{t('VoIP_allow_and_accept')}
</Button>,
];
case 'outgoingPrompt':
return [
<Button key='cancel' onClick={onClose}>
{t('Cancel')}
</Button>,
<Button key='confirm' success onClick={onConfirm} icon='phone'>
{t('VoIP_allow_and_call')}
</Button>,
];
case 'deviceChangePrompt':
return [
<Button key='cancel' onClick={onClose}>
{t('Cancel')}
</Button>,
<Button key='confirm' primary onClick={onConfirm}>
{t('Allow')}
</Button>,
];
}
};
const PermissionFlowModal = ({ onCancel, onConfirm, type }: PermissionFlowModalProps) => {
const { t } = useTranslation();
const modalId = useId();
const absoluteUrl = useAbsoluteUrl();
const setModal = useSetModal();
const onClose = () => {
setModal(null);
};
return (
<Modal aria-labelledby={modalId}>
<ModalHeader>
<ModalTitle id={modalId}>{t('VoIP_device_permission_required')}</ModalTitle>
<ModalClose aria-label={t('Close')} onClick={onClose} />
</ModalHeader>
<ModalContent>
<Box is='span' className={breakSpaces} fontScale='p2'>
{t('VoIP_device_permission_required_description', {
workspaceUrl: absoluteUrl(''),
})}
</Box>
</ModalContent>
<ModalFooter>
<ModalFooterControllers>{getFooter(type, { onCancel, onConfirm, onClose, t })}</ModalFooterControllers>
</ModalFooter>
</Modal>
);
};
export default PermissionFlowModal;

View File

@ -0,0 +1,361 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders PermissionFlowModalDenied without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby=":r0:"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-1e2ego0"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title rcx-css-trljwa rcx-css-lma364"
id=":r0:"
>
Mic/speaker access required
</h2>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-56vo9k"
>
<span
class="rcx-box rcx-box--full rcx-css-1ntdv01 rcx-css-1gd28qh"
>
Your web browser stopped http://localhost:3000/ from using your microphone and/or speaker.
Allow speaker and microphone access in your browser settings to prevent seeing this message again.
</span>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button rcx-button-group__item"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`renders PermissionFlowModalDeviceChangePrompt without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby=":r1:"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-1e2ego0"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title rcx-css-trljwa rcx-css-lma364"
id=":r1:"
>
Mic/speaker access required
</h2>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-56vo9k"
>
<span
class="rcx-box rcx-box--full rcx-css-1ntdv01 rcx-css-1gd28qh"
>
Your web browser stopped http://localhost:3000/ from using your microphone and/or speaker.
Allow speaker and microphone access in your browser settings to prevent seeing this message again.
</span>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button rcx-button-group__item"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button rcx-button-group__item"
type="button"
>
<span
class="rcx-button--content"
>
Allow
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`renders PermissionFlowModalIncomingPrompt without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby=":r2:"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-1e2ego0"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title rcx-css-trljwa rcx-css-lma364"
id=":r2:"
>
Mic/speaker access required
</h2>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-56vo9k"
>
<span
class="rcx-box rcx-box--full rcx-css-1ntdv01 rcx-css-1gd28qh"
>
Your web browser stopped http://localhost:3000/ from using your microphone and/or speaker.
Allow speaker and microphone access in your browser settings to prevent seeing this message again.
</span>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--danger rcx-button rcx-button-group__item"
type="button"
>
<span
class="rcx-button--content"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-phone-off rcx-icon rcx-css-1hdf9ok"
>
</i>
Cancel and reject
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--success rcx-button rcx-button-group__item"
type="button"
>
<span
class="rcx-button--content"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-phone rcx-icon rcx-css-1hdf9ok"
>
</i>
Allow and accept
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`renders PermissionFlowModalOutgoingPrompt without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby=":r3:"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-1e2ego0"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title rcx-css-trljwa rcx-css-lma364"
id=":r3:"
>
Mic/speaker access required
</h2>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-56vo9k"
>
<span
class="rcx-box rcx-box--full rcx-css-1ntdv01 rcx-css-1gd28qh"
>
Your web browser stopped http://localhost:3000/ from using your microphone and/or speaker.
Allow speaker and microphone access in your browser settings to prevent seeing this message again.
</span>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button rcx-button-group__item"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--success rcx-button rcx-button-group__item"
type="button"
>
<span
class="rcx-button--content"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-phone rcx-icon rcx-css-1hdf9ok"
>
</i>
Allow and call
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;

View File

@ -1,3 +1,4 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryFn } from '@storybook/react';
import VoipActions from './VoipActions';
@ -7,6 +8,11 @@ const noop = () => undefined;
export default {
title: 'Components/VoipActions',
component: VoipActions,
decorators: [
mockAppRoot()
.withMicrophonePermissionState({ state: 'granted' } as PermissionStatus)
.buildStoryDecorator(),
],
} satisfies Meta<typeof VoipActions>;
export const IncomingActions: StoryFn<typeof VoipActions> = () => {

View File

@ -1,6 +1,7 @@
import { ButtonGroup } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { useDevicePermissionPrompt } from '../../hooks/useDevicePermissionPrompt';
import ActionButton from '../VoipActionButton';
type VoipGenericActionsProps = {
@ -37,6 +38,12 @@ const isOngoing = (props: VoipActionsProps): props is VoipOngoingActionsProps =>
const VoipActions = ({ isMuted, isHeld, isDTMFActive, isTransferActive, ...events }: VoipActionsProps) => {
const { t } = useTranslation();
const onAcceptIncoming = useDevicePermissionPrompt({
actionType: 'incoming',
onAccept: events.onAccept ?? (() => undefined),
onReject: events.onDecline ?? (() => undefined),
});
return (
<ButtonGroup large>
{isIncoming(events) && <ActionButton danger label={t('Decline')} icon='phone-off' onClick={events.onDecline} />}
@ -77,7 +84,7 @@ const VoipActions = ({ isMuted, isHeld, isDTMFActive, isTransferActive, ...event
{isOngoing(events) && <ActionButton danger label={t('End_call')} icon='phone-off' disabled={isHeld} onClick={events.onEndCall} />}
{isIncoming(events) && <ActionButton success label={t('Accept')} icon='phone' onClick={events.onAccept} />}
{isIncoming(events) && <ActionButton success label={t('Accept')} icon='phone' onClick={onAcceptIncoming} />}
</ButtonGroup>
);
};

View File

@ -1,3 +1,4 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryFn } from '@storybook/react';
import VoipPopup from './VoipPopup';
@ -5,6 +6,8 @@ import { createMockVoipProviders } from '../../tests/mocks';
const [MockedProviders, voipClient] = createMockVoipProviders();
const appRoot = mockAppRoot().withMicrophonePermissionState({ state: 'granted' } as PermissionStatus);
export default {
title: 'Components/VoipPopup',
component: VoipPopup,
@ -14,6 +17,7 @@ export default {
<Story />
</MockedProviders>
),
appRoot.buildStoryDecorator(),
],
} satisfies Meta<typeof VoipPopup>;

View File

@ -11,8 +11,20 @@ jest.mock('../../../hooks/useVoipAPI', () => ({
useVoipAPI: jest.fn(() => ({ makeCall, closeDialer })),
}));
Object.defineProperty(global.navigator, 'mediaDevices', {
value: {
getUserMedia: jest.fn().mockImplementation(() => {
return Promise.resolve({
getTracks: () => [],
});
}),
},
});
const appRoot = mockAppRoot().withMicrophonePermissionState({ state: 'granted' } as PermissionStatus);
it('should look good', async () => {
render(<VoipDialerView />, { wrapper: mockAppRoot().build() });
render(<VoipDialerView />, { wrapper: appRoot.build() });
expect(screen.getByText('New_Call')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Device_settings/ })).toBeInTheDocument();
@ -20,7 +32,7 @@ it('should look good', async () => {
});
it('should only enable call button if input has value (keyboard)', async () => {
render(<VoipDialerView />, { wrapper: mockAppRoot().build() });
render(<VoipDialerView />, { wrapper: appRoot.build() });
expect(screen.getByRole('button', { name: /Call/i })).toBeDisabled();
await userEvent.type(screen.getByLabelText('Phone_number'), '123');
@ -28,7 +40,7 @@ it('should only enable call button if input has value (keyboard)', async () => {
});
it('should only enable call button if input has value (mouse)', async () => {
render(<VoipDialerView />, { wrapper: mockAppRoot().build() });
render(<VoipDialerView />, { wrapper: appRoot.build() });
expect(screen.getByRole('button', { name: /Call/i })).toBeDisabled();
@ -39,7 +51,7 @@ it('should only enable call button if input has value (mouse)', async () => {
});
it('should call methods makeCall and closeDialer when call button is clicked', async () => {
render(<VoipDialerView />, { wrapper: mockAppRoot().build() });
render(<VoipDialerView />, { wrapper: appRoot.build() });
await userEvent.type(screen.getByLabelText('Phone_number'), '123');
await userEvent.click(screen.getByTestId(`dial-pad-button-1`));

View File

@ -3,6 +3,7 @@ import { useState, forwardRef, Ref } from 'react';
import { useTranslation } from 'react-i18next';
import { VoipDialPad as DialPad, VoipSettingsButton as SettingsButton } from '../..';
import { useDevicePermissionPrompt } from '../../../hooks/useDevicePermissionPrompt';
import { useVoipAPI } from '../../../hooks/useVoipAPI';
import type { PositionOffsets } from '../components/VoipPopupContainer';
import Container from '../components/VoipPopupContainer';
@ -20,10 +21,13 @@ const VoipDialerView = forwardRef<HTMLDivElement, VoipDialerViewProps>(function
const { makeCall, closeDialer } = useVoipAPI();
const [number, setNumber] = useState('');
const handleCall = () => {
makeCall(number);
closeDialer();
};
const handleCall = useDevicePermissionPrompt({
actionType: 'outgoing',
onAccept: () => {
makeCall(number);
closeDialer();
},
});
return (
<Container ref={ref} data-testid='vc-popup-dialer' position={position} {...props}>
@ -38,7 +42,7 @@ const VoipDialerView = forwardRef<HTMLDivElement, VoipDialerViewProps>(function
<Footer>
<ButtonGroup large>
<SettingsButton />
<Button medium success icon='phone' disabled={!number} flexGrow={1} onClick={handleCall}>
<Button medium success icon='phone' disabled={!number} flexGrow={1} onClick={() => handleCall()}>
{t('Call')}
</Button>
</ButtonGroup>

View File

@ -5,10 +5,22 @@ import userEvent from '@testing-library/user-event';
import VoipIncomingView from './VoipIncomingView';
import { createMockFreeSwitchExtensionDetails, createMockVoipIncomingSession } from '../../../tests/mocks';
const appRoot = mockAppRoot().withEndpoint('GET', '/v1/voip-freeswitch.extension.getDetails', () => createMockFreeSwitchExtensionDetails());
const appRoot = mockAppRoot()
.withEndpoint('GET', '/v1/voip-freeswitch.extension.getDetails', () => createMockFreeSwitchExtensionDetails())
.withMicrophonePermissionState({ state: 'granted' } as PermissionStatus);
const incomingSession = createMockVoipIncomingSession();
Object.defineProperty(global.navigator, 'mediaDevices', {
value: {
getUserMedia: jest.fn().mockImplementation(() => {
return Promise.resolve({
getTracks: () => [],
});
}),
},
});
it('should properly render incoming view', async () => {
render(<VoipIncomingView session={incomingSession} />, { wrapper: appRoot.build() });

View File

@ -1,10 +1,12 @@
/* eslint-disable react/no-multi-comp */
import { IconButton } from '@rocket.chat/fuselage';
import { useSafely } from '@rocket.chat/fuselage-hooks';
import { GenericMenu } from '@rocket.chat/ui-client';
import type { ComponentProps, Ref } from 'react';
import { forwardRef } from 'react';
import { forwardRef, useCallback, useState } from 'react';
import { useVoipDeviceSettings } from './hooks/useVoipDeviceSettings';
import { useDevicePermissionPrompt } from '../../hooks/useDevicePermissionPrompt';
const CustomizeButton = forwardRef(function CustomizeButton(
{ mini, ...props }: ComponentProps<typeof IconButton>,
@ -15,8 +17,31 @@ const CustomizeButton = forwardRef(function CustomizeButton(
});
const VoipSettingsButton = ({ mini = false }: { mini?: boolean }) => {
const [isOpen, setIsOpen] = useSafely(useState(false));
const menu = useVoipDeviceSettings();
const _onOpenChange = useDevicePermissionPrompt({
actionType: 'device-change',
onAccept: () => {
setIsOpen(true);
},
onReject: () => {
setIsOpen(false);
},
});
const onOpenChange = useCallback(
(isOpen: boolean) => {
if (isOpen) {
_onOpenChange();
return;
}
setIsOpen(isOpen);
},
[_onOpenChange, setIsOpen],
);
return (
<GenericMenu
is={CustomizeButton}
@ -27,6 +52,8 @@ const VoipSettingsButton = ({ mini = false }: { mini?: boolean }) => {
placement='top-end'
icon='customize'
mini={mini}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
);
};

View File

@ -7,6 +7,18 @@ import { useTranslation } from 'react-i18next';
import { useVoipAPI } from '../../../hooks/useVoipAPI';
// TODO: Ensure that there's never more than one default device item
// if there's more than one, we need to change the label and id.
const getDefaultDeviceItem = (label: string, type: 'input' | 'output') => ({
content: (
<Box is='span' title={label} fontSize={14}>
{label}
</Box>
),
addon: <RadioButton onChange={() => undefined} checked={true} disabled />,
id: `default-${type}`,
});
export const useVoipDeviceSettings = () => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
@ -28,32 +40,50 @@ export const useVoipDeviceSettings = () => {
});
const availableInputDevice =
availableDevices?.audioInput?.map<GenericMenuItemProps>((device) => ({
id: device.id,
content: (
<Box is='span' title={device.label} fontSize={14}>
{device.label}
</Box>
),
addon: <RadioButton onChange={() => changeInputDevice.mutate(device)} checked={device.id === selectedAudioDevices?.audioInput?.id} />,
})) || [];
availableDevices?.audioInput?.map<GenericMenuItemProps>((device) => {
if (!device.id || !device.label) {
return getDefaultDeviceItem(t('Default'), 'input');
}
return {
// We need to change the id because in some cases, the id is the same for input and output devices.
// For example, in chrome, the `id` for the default input and output devices is the same ('default').
// Also, some devices can have different functions for the same device (such as a webcam that has a microphone)
id: `${device.id}-input`,
content: (
<Box is='span' title={device.label} fontSize={14}>
{device.label}
</Box>
),
addon: (
<RadioButton onChange={() => changeInputDevice.mutate(device)} checked={device.id === selectedAudioDevices?.audioInput?.id} />
),
};
}) || [];
const availableOutputDevice =
availableDevices?.audioOutput?.map<GenericMenuItemProps>((device) => ({
id: device.id,
content: (
<Box is='span' title={device.label} fontSize={14}>
{device.label}
</Box>
),
addon: (
<RadioButton onChange={() => changeOutputDevice.mutate(device)} checked={device.id === selectedAudioDevices?.audioOutput?.id} />
),
onClick(e?: MouseEvent<HTMLElement>) {
e?.preventDefault();
e?.stopPropagation();
},
})) || [];
availableDevices?.audioOutput?.map<GenericMenuItemProps>((device) => {
if (!device.id || !device.label) {
return getDefaultDeviceItem(t('Default'), 'output');
}
return {
// Same here, the id's might not be unique.
id: `${device.id}-output`,
content: (
<Box is='span' title={device.label} fontSize={14}>
{device.label}
</Box>
),
addon: (
<RadioButton onChange={() => changeOutputDevice.mutate(device)} checked={device.id === selectedAudioDevices?.audioOutput?.id} />
),
onClick(e?: MouseEvent<HTMLElement>) {
e?.preventDefault();
e?.stopPropagation();
},
};
}) || [];
const micSection = {
title: t('Microphone'),
@ -65,7 +95,7 @@ export const useVoipDeviceSettings = () => {
items: availableOutputDevice,
};
const disabled = !micSection.items.length || !speakerSection.items.length;
const disabled = availableOutputDevice.length === 0 && availableInputDevice.length === 0;
return {
disabled,

View File

@ -4,3 +4,4 @@ export * from './useVoipClient';
export * from './useVoipDialer';
export * from './useVoipSession';
export * from './useVoipState';
export * from './useDevicePermissionPrompt';

View File

@ -0,0 +1,197 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { ModalProvider, ModalRegion } from '@rocket.chat/ui-client';
import { renderHook, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ReactNode } from 'react';
import { useDevicePermissionPrompt } from './useDevicePermissionPrompt';
const types = ['device-change', 'outgoing', 'incoming'] as const;
const modalWrapper = (children: ReactNode) => {
return (
<ModalProvider>
<ModalRegion />
{children}
</ModalProvider>
);
};
const appRoot = mockAppRoot()
.withTranslations('en', 'core', {
VoIP_device_permission_required: 'Mic/speaker access required',
VoIP_allow_and_call: 'Allow and call',
VoIP_allow_and_accept: 'Allow and accept',
VoIP_cancel_and_reject: 'Cancel and reject',
Cancel: 'Cancel',
VoIP_device_permission_required_description:
'Your web browser stopped {{workspaceUrl}} from using your microphone and/or speaker.\n\nAllow speaker and microphone access in your browser settings to prevent seeing this message again.',
})
.wrap(modalWrapper);
const onAccept = jest.fn(() => undefined);
const onReject = jest.fn(() => undefined);
Object.defineProperty(global.navigator, 'mediaDevices', {
value: {
getUserMedia: jest.fn(async () => ({ getTracks: () => [] })),
},
});
describe.each(types)('useDevicePermissionPrompt - Action: %s', (actionType) => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Should immediately call onAccept (permission granted)', async () => {
const { result } = renderHook(() => useDevicePermissionPrompt({ onAccept, onReject, actionType }), {
wrapper: appRoot.withMicrophonePermissionState({ state: 'granted' } as PermissionStatus).build(),
});
act(() => {
result.current();
});
await waitFor(() => {
expect(onAccept).toHaveBeenCalled();
expect(result.current).toBeInstanceOf(Function);
});
});
it('Should open "denied" modal (permission denied)', async () => {
const { result } = renderHook(() => useDevicePermissionPrompt({ onAccept, onReject, actionType }), {
wrapper: appRoot.withMicrophonePermissionState({ state: 'denied' } as PermissionStatus).build(),
});
act(() => {
result.current();
});
const cancel = await screen.findByText('Cancel');
expect(cancel).toBeInTheDocument();
await userEvent.click(cancel);
expect(onReject).not.toHaveBeenCalled();
// There is no accept button in the denied state modal
expect(onAccept).not.toHaveBeenCalled();
expect(cancel).not.toBeInTheDocument();
});
});
describe('useDevicePermissionPrompt - Permission state: prompt', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Should open "incoming - prompt" modal and call respective actions', async () => {
const { result } = renderHook(() => useDevicePermissionPrompt({ onAccept, onReject, actionType: 'incoming' }), {
wrapper: appRoot.withMicrophonePermissionState({ state: 'prompt' } as PermissionStatus).build(),
});
act(() => {
result.current();
});
const accept = await screen.findByText('Allow and accept');
expect(accept).toBeInTheDocument();
expect(await screen.findByText('Cancel and reject')).toBeInTheDocument();
await userEvent.click(accept);
expect(onAccept).toHaveBeenCalled();
expect(onReject).not.toHaveBeenCalled();
expect(screen.queryByText('Allow and accept')).not.toBeInTheDocument();
jest.clearAllMocks();
act(() => {
result.current();
});
const cancel = await screen.findByText('Cancel and reject');
expect(cancel).toBeInTheDocument();
expect(await screen.findByText('Allow and accept')).toBeInTheDocument();
await userEvent.click(cancel);
expect(onReject).toHaveBeenCalled();
expect(onAccept).not.toHaveBeenCalled();
expect(cancel).not.toBeInTheDocument();
});
it('Should open "outgoing - prompt" modal and call respective actions', async () => {
const { result } = renderHook(() => useDevicePermissionPrompt({ onAccept, onReject, actionType: 'outgoing' }), {
wrapper: appRoot.withMicrophonePermissionState({ state: 'prompt' } as PermissionStatus).build(),
});
act(() => {
result.current();
});
const accept = await screen.findByText('Allow and call');
expect(accept).toBeInTheDocument();
expect(await screen.findByText('Cancel')).toBeInTheDocument();
await userEvent.click(accept);
expect(onAccept).toHaveBeenCalled();
expect(onReject).not.toHaveBeenCalled();
expect(screen.queryByText('Allow and call')).not.toBeInTheDocument();
jest.clearAllMocks();
act(() => {
result.current();
});
const cancel = await screen.findByText('Cancel');
expect(cancel).toBeInTheDocument();
expect(await screen.findByText('Allow and call')).toBeInTheDocument();
await userEvent.click(cancel);
// The outgoing prompt modal only closes when the user clicks the cancel button, no function is called.
expect(onReject).not.toHaveBeenCalled();
expect(onAccept).not.toHaveBeenCalled();
expect(cancel).not.toBeInTheDocument();
});
it('Should open "device-change - prompt" modal and call respective actions', async () => {
const { result } = renderHook(() => useDevicePermissionPrompt({ onAccept, onReject, actionType: 'device-change' }), {
wrapper: appRoot.withMicrophonePermissionState({ state: 'prompt' } as PermissionStatus).build(),
});
act(() => {
result.current();
});
const accept = await screen.findByText('Allow');
expect(accept).toBeInTheDocument();
expect(await screen.findByText('Cancel')).toBeInTheDocument();
await userEvent.click(accept);
expect(onAccept).toHaveBeenCalled();
expect(onReject).not.toHaveBeenCalled();
expect(screen.queryByText('Allow')).not.toBeInTheDocument();
jest.clearAllMocks();
act(() => {
result.current();
});
const cancel = await screen.findByText('Cancel');
expect(cancel).toBeInTheDocument();
expect(await screen.findByText('Allow')).toBeInTheDocument();
await userEvent.click(cancel);
// The device-change modal only closes when the user clicks the cancel button, no function is called.
expect(onReject).not.toHaveBeenCalled();
expect(onAccept).not.toHaveBeenCalled();
expect(cancel).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,123 @@
import { useMediaDeviceMicrophonePermission, useSetInputMediaDevice, useSetModal } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import PermissionFlowModal, { type PermissionFlowModalType } from '../components/PermissionFlow/PermissionFlowModal';
type OnAccept = (stream: MediaStream) => void;
type OnReject = (error?: DOMException) => void;
type DeviceChangePromptProps = {
onAccept: OnAccept;
onReject?: OnReject;
actionType: 'device-change';
};
type OutgoingPromptProps = {
onAccept: OnAccept;
onReject?: OnReject;
actionType: 'outgoing';
};
type IncomingPromptProps = {
onAccept: OnAccept;
onReject: OnReject;
actionType: 'incoming';
};
type UseDevicePermissionPromptProps = DeviceChangePromptProps | OutgoingPromptProps | IncomingPromptProps;
const getModalType = (
actionType: UseDevicePermissionPromptProps['actionType'],
state: Exclude<PermissionState, 'granted'>,
): PermissionFlowModalType => {
if (state === 'denied') {
return 'denied';
}
if (actionType === 'device-change') {
return 'deviceChangePrompt';
}
if (actionType === 'outgoing') {
return 'outgoingPrompt';
}
// actionType === 'incoming'
return 'incomingPrompt';
};
export const useDevicePermissionPrompt = ({ onAccept: _onAccept, onReject, actionType }: UseDevicePermissionPromptProps) => {
const { state, requestDevice } = useMediaDeviceMicrophonePermission();
const setModal = useSetModal();
const setInputMediaDevice = useSetInputMediaDevice();
const queryClient = useQueryClient();
return useCallback(
(stopTracks = true) => {
const onAccept = (stream: MediaStream) => {
// Since we now have requested a stream, we can now invalidate the devices list and generate a complete one.
// Obs2: Safari does not seem to be dispatching the change event when permission is granted, so we need to invalidate the permission query as well.
queryClient.invalidateQueries({ queryKey: ['media-devices-list'] });
stream.getTracks().forEach((track) => {
const { deviceId } = track.getSettings();
if (!deviceId) {
return;
}
if (track.kind === 'audio' && navigator.mediaDevices.enumerateDevices) {
navigator.mediaDevices.enumerateDevices().then((devices) => {
const device = devices.find((device) => device.deviceId === deviceId);
if (!device) {
return;
}
setInputMediaDevice({
id: device.deviceId,
label: device.label,
type: 'audioinput',
});
});
}
});
_onAccept(stream);
// For now we only need this stream to be able to list the devices (firefox doesn't list devices without a stream)
// and also to get the selected device from the tracks settings (firefox requests permission per device)
// This is set as a flag in case we need to use the stream in the future.
if (stopTracks) {
stream.getTracks().forEach((track) => {
track.stop();
});
}
};
if (state === 'granted') {
requestDevice({
onAccept,
});
return;
}
const onConfirm = () => {
requestDevice?.({
onReject,
onAccept: (...args) => {
onAccept(...args);
setModal(null);
},
});
};
const onCancel = () => {
if (onReject) {
onReject();
}
setModal(null);
};
setModal(<PermissionFlowModal type={getModalType(actionType, state)} onCancel={onCancel} onConfirm={onConfirm} />);
},
[state, setModal, actionType, queryClient, _onAccept, setInputMediaDevice, requestDevice, onReject],
);
};

View File

@ -20,6 +20,16 @@ import { defaultMediaStreamFactory } from 'sip.js/lib/platform/web';
import Stream from './Stream';
export default class LocalStream extends Stream {
// This is used to change the input device before a call is started/answered.
// Some browsers request permission per-device, this ensures the permission will be prompted.
static changeInputDeviceOffline(constraints: MediaStreamConstraints) {
navigator.mediaDevices.getUserMedia(constraints).then((stream) => {
stream.getTracks().forEach((track) => {
track.stop();
});
});
}
static async requestNewStream(constraints: MediaStreamConstraints, session: Session): Promise<MediaStream | undefined> {
const factory: MediaStreamFactory = defaultMediaStreamFactory();
if (session?.sessionDescriptionHandler) {

View File

@ -409,7 +409,7 @@ class VoipClient extends Emitter<VoipEvents> {
public async changeAudioInputDevice(constraints: MediaStreamConstraints): Promise<boolean> {
if (!this.session) {
console.warn('changeAudioInputDevice() : No session.');
LocalStream.changeInputDeviceOffline(constraints);
return false;
}

View File

@ -9068,14 +9068,17 @@ __metadata:
"@rocket.chat/fuselage-hooks": "npm:^0.37.0"
"@rocket.chat/fuselage-tokens": "npm:~0.33.2"
"@rocket.chat/i18n": "workspace:~"
"@rocket.chat/jest-presets": "workspace:~"
"@rocket.chat/password-policies": "workspace:^"
"@rocket.chat/rest-typings": "workspace:^"
"@rocket.chat/tools": "workspace:~"
"@types/jest": "npm:~30.0.0"
"@types/react": "npm:~18.3.23"
"@types/react-dom": "npm:~18.3.7"
eslint: "npm:~8.45.0"
eslint-plugin-react-hooks: "npm:^5.0.0"
i18next: "npm:~23.4.9"
jest: "npm:~30.0.2"
mongodb: "npm:6.10.0"
react: "npm:~18.3.1"
typescript: "npm:~5.9.2"
@ -24615,7 +24618,7 @@ __metadata:
languageName: node
linkType: hard
"jest@npm:~30.0.5":
"jest@npm:~30.0.2, jest@npm:~30.0.5":
version: 30.0.5
resolution: "jest@npm:30.0.5"
dependencies: