mirror of
https://github.com/RocketChat/Rocket.Chat.git
synced 2025-12-28 06:47:25 +00:00
feat: Voice call device permission flow (#36397)
This commit is contained in:
parent
ae48139629
commit
c6ef437d90
10
.changeset/weak-windows-doubt.md
Normal file
10
.changeset/weak-windows-doubt.md
Normal 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.
|
||||
@ -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]);
|
||||
};
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -11,6 +11,7 @@ type GenericMenuCommonProps = {
|
||||
icon?: ComponentProps<typeof IconButton>['icon'];
|
||||
disabled?: boolean;
|
||||
callbackAction?: () => void;
|
||||
isOpen?: boolean;
|
||||
};
|
||||
|
||||
type GenericMenuConditionalProps =
|
||||
|
||||
12
packages/ui-contexts/jest.config.ts
Normal file
12
packages/ui-contexts/jest.config.ts
Normal 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;
|
||||
@ -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",
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
58
packages/ui-contexts/src/hooks/useMediaDevicePermission.ts
Normal file
58
packages/ui-contexts/src/hooks/useMediaDevicePermission.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@ -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';
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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',
|
||||
};
|
||||
@ -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;
|
||||
@ -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>
|
||||
`;
|
||||
@ -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> = () => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>;
|
||||
|
||||
|
||||
@ -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`));
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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() });
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -4,3 +4,4 @@ export * from './useVoipClient';
|
||||
export * from './useVoipDialer';
|
||||
export * from './useVoipSession';
|
||||
export * from './useVoipState';
|
||||
export * from './useDevicePermissionPrompt';
|
||||
|
||||
197
packages/ui-voip/src/hooks/useDevicePermissionPrompt.spec.tsx
Normal file
197
packages/ui-voip/src/hooks/useDevicePermissionPrompt.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
123
packages/ui-voip/src/hooks/useDevicePermissionPrompt.tsx
Normal file
123
packages/ui-voip/src/hooks/useDevicePermissionPrompt.tsx
Normal 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],
|
||||
);
|
||||
};
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user