diff --git a/.changeset/weak-windows-doubt.md b/.changeset/weak-windows-doubt.md new file mode 100644 index 0000000000000..9dfcde6067fc7 --- /dev/null +++ b/.changeset/weak-windows-doubt.md @@ -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. diff --git a/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx b/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx index e9dc459951814..a9920688b59cf 100644 --- a/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx +++ b/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx @@ -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]); }; diff --git a/apps/meteor/client/providers/DeviceProvider/DeviceProvider.tsx b/apps/meteor/client/providers/DeviceProvider/DeviceProvider.tsx index 358f4e7ea9988..afa2e2b236d4b 100644 --- a/apps/meteor/client/providers/DeviceProvider/DeviceProvider.tsx +++ b/apps/meteor/client/providers/DeviceProvider/DeviceProvider.tsx @@ -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; }; -export const DeviceProvider = ({ children }: DeviceProviderProps): ReactElement => { - const [enabled] = useState(typeof isSecureContext && isSecureContext); - const [availableAudioOutputDevices, setAvailableAudioOutputDevices] = useState([]); - const [availableAudioInputDevices, setAvailableAudioInputDevices] = useState([]); - const [selectedAudioOutputDevice, setSelectedAudioOutputDevice] = useState({ - id: 'default', +const defaultDevices = { + audioInput: [], + audioOutput: [], + defaultAudioOutputDevice: { + id: '', label: '', - type: 'audio', - }); - const [selectedAudioInputDevice, setSelectedAudioInputDevice] = useState({ - id: 'default', + type: 'audiooutput', + }, + defaultAudioInputDevice: { + id: '', label: '', - type: 'audio', - }); + type: 'audioinput', + }, +}; + +const devicesQueryKey = ['media-devices-list']; + +export const DeviceProvider = ({ children }: DeviceProviderProps): ReactElement => { + const [enabled] = useState(typeof isSecureContext && isSecureContext); + const [selectedAudioOutputDevice, setSelectedAudioOutputDevice] = useState(undefined); + const [selectedAudioInputDevice, setSelectedAudioInputDevice] = useState(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 {children}; }; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 55a8d6d166e8c..b9444e34604f5 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -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", diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index 228b6b9276f83..7f1b1150a493c 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -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(); - private audioInputDevices: Device[] = []; - - private audioOutputDevices: Device[] = []; + private deviceContext: Partial> = { + 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): ContextType => { return { @@ -630,10 +642,7 @@ export class MockedAppRootBuilder { */} - + {/* diff --git a/packages/mock-providers/src/MockedDeviceContext.tsx b/packages/mock-providers/src/MockedDeviceContext.tsx index 0b861b7e13096..adfb7bac50b83 100644 --- a/packages/mock-providers/src/MockedDeviceContext.tsx +++ b/packages/mock-providers/src/MockedDeviceContext.tsx @@ -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, }; diff --git a/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx b/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx index 39b2c2e2a2c81..0c714a2acc09e 100644 --- a/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx +++ b/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx @@ -11,6 +11,7 @@ type GenericMenuCommonProps = { icon?: ComponentProps['icon']; disabled?: boolean; callbackAction?: () => void; + isOpen?: boolean; }; type GenericMenuConditionalProps = diff --git a/packages/ui-contexts/jest.config.ts b/packages/ui-contexts/jest.config.ts new file mode 100644 index 0000000000000..45bc947f274c2 --- /dev/null +++ b/packages/ui-contexts/jest.config.ts @@ -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($|/.+)': '/../../node_modules/react$1', + '^react-dom($|/.+)': '/../../node_modules/react-dom$1', + '^react-i18next($|/.+)': '/../../node_modules/react-i18next$1', + }, +} satisfies Config; diff --git a/packages/ui-contexts/package.json b/packages/ui-contexts/package.json index b8cba15fee64e..a96416dad6046 100644 --- a/packages/ui-contexts/package.json +++ b/packages/ui-contexts/package.json @@ -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", diff --git a/packages/ui-contexts/src/DeviceContext.ts b/packages/ui-contexts/src/DeviceContext.ts index 5bbda60cfcd56..2911081bf75ce 100644 --- a/packages/ui-contexts/src/DeviceContext.ts +++ b/packages/ui-contexts/src/DeviceContext.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 = { diff --git a/packages/ui-contexts/src/hooks/useMediaDevicePermission.spec.tsx b/packages/ui-contexts/src/hooks/useMediaDevicePermission.spec.tsx new file mode 100644 index 0000000000000..fe6e5385b9bbd --- /dev/null +++ b/packages/ui-contexts/src/hooks/useMediaDevicePermission.spec.tsx @@ -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 ( + undefined, + setAudioInputDevice: () => undefined, + }} + > + {children} + + ); + }; + +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'); + }); +}); diff --git a/packages/ui-contexts/src/hooks/useMediaDevicePermission.ts b/packages/ui-contexts/src/hooks/useMediaDevicePermission.ts new file mode 100644 index 0000000000000..2edee8cc407b8 --- /dev/null +++ b/packages/ui-contexts/src/hooks/useMediaDevicePermission.ts @@ -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 => { + 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, + }; +}; diff --git a/packages/ui-contexts/src/index.ts b/packages/ui-contexts/src/index.ts index 954193351beb6..b30813e345a41 100644 --- a/packages/ui-contexts/src/index.ts +++ b/packages/ui-contexts/src/index.ts @@ -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'; diff --git a/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.spec.tsx b/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.spec.tsx new file mode 100644 index 0000000000000..2fbb3b251a4d8 --- /dev/null +++ b/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.spec.tsx @@ -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(); + expect(view.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); diff --git a/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.stories.tsx b/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.stories.tsx new file mode 100644 index 0000000000000..86f9815337519 --- /dev/null +++ b/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.stories.tsx @@ -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 => , + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +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', +}; diff --git a/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.tsx b/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.tsx new file mode 100644 index 0000000000000..9674a5a7ec211 --- /dev/null +++ b/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.tsx @@ -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['t'] }, +) => { + switch (type) { + case 'denied': + return [ + , + ]; + case 'incomingPrompt': + return [ + , + , + ]; + case 'outgoingPrompt': + return [ + , + , + ]; + case 'deviceChangePrompt': + return [ + , + , + ]; + } +}; + +const PermissionFlowModal = ({ onCancel, onConfirm, type }: PermissionFlowModalProps) => { + const { t } = useTranslation(); + const modalId = useId(); + const absoluteUrl = useAbsoluteUrl(); + const setModal = useSetModal(); + + const onClose = () => { + setModal(null); + }; + + return ( + + + {t('VoIP_device_permission_required')} + + + + + {t('VoIP_device_permission_required_description', { + workspaceUrl: absoluteUrl(''), + })} + + + + {getFooter(type, { onCancel, onConfirm, onClose, t })} + + + ); +}; + +export default PermissionFlowModal; diff --git a/packages/ui-voip/src/components/PermissionFlow/__snapshots__/PermissionFlowModal.spec.tsx.snap b/packages/ui-voip/src/components/PermissionFlow/__snapshots__/PermissionFlowModal.spec.tsx.snap new file mode 100644 index 0000000000000..2c5ba57f846e4 --- /dev/null +++ b/packages/ui-voip/src/components/PermissionFlow/__snapshots__/PermissionFlowModal.spec.tsx.snap @@ -0,0 +1,361 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders PermissionFlowModalDenied without crashing 1`] = ` + +
+ +
+
+
+

+ Mic/speaker access required +

+ +
+
+
+
+ + 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. + +
+
+ +
+
+
+ +`; + +exports[`renders PermissionFlowModalDeviceChangePrompt without crashing 1`] = ` + +
+ +
+
+
+

+ Mic/speaker access required +

+ +
+
+
+
+ + 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. + +
+
+ +
+
+
+ +`; + +exports[`renders PermissionFlowModalIncomingPrompt without crashing 1`] = ` + +
+ +
+
+
+

+ Mic/speaker access required +

+ +
+
+
+
+ + 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. + +
+
+ +
+
+
+ +`; + +exports[`renders PermissionFlowModalOutgoingPrompt without crashing 1`] = ` + +
+ +
+
+
+

+ Mic/speaker access required +

+ +
+
+
+
+ + 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. + +
+
+ +
+
+
+ +`; diff --git a/packages/ui-voip/src/components/VoipActions/VoipActions.stories.tsx b/packages/ui-voip/src/components/VoipActions/VoipActions.stories.tsx index 07c5601c5900a..622b4bbc25473 100644 --- a/packages/ui-voip/src/components/VoipActions/VoipActions.stories.tsx +++ b/packages/ui-voip/src/components/VoipActions/VoipActions.stories.tsx @@ -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; export const IncomingActions: StoryFn = () => { diff --git a/packages/ui-voip/src/components/VoipActions/VoipActions.tsx b/packages/ui-voip/src/components/VoipActions/VoipActions.tsx index 0cd3c95a2dc21..d17a68791a8d9 100644 --- a/packages/ui-voip/src/components/VoipActions/VoipActions.tsx +++ b/packages/ui-voip/src/components/VoipActions/VoipActions.tsx @@ -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 ( {isIncoming(events) && } @@ -77,7 +84,7 @@ const VoipActions = ({ isMuted, isHeld, isDTMFActive, isTransferActive, ...event {isOngoing(events) && } - {isIncoming(events) && } + {isIncoming(events) && } ); }; diff --git a/packages/ui-voip/src/components/VoipPopup/VoipPopup.stories.tsx b/packages/ui-voip/src/components/VoipPopup/VoipPopup.stories.tsx index 4c5a72497b68c..6939e30fe0e79 100644 --- a/packages/ui-voip/src/components/VoipPopup/VoipPopup.stories.tsx +++ b/packages/ui-voip/src/components/VoipPopup/VoipPopup.stories.tsx @@ -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 { ), + appRoot.buildStoryDecorator(), ], } satisfies Meta; diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.spec.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.spec.tsx index 7a55f50fd09e6..b2e52863bf6b4 100644 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.spec.tsx +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.spec.tsx @@ -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(, { wrapper: mockAppRoot().build() }); + render(, { 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(, { wrapper: mockAppRoot().build() }); + render(, { 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(, { wrapper: mockAppRoot().build() }); + render(, { 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(, { wrapper: mockAppRoot().build() }); + render(, { wrapper: appRoot.build() }); await userEvent.type(screen.getByLabelText('Phone_number'), '123'); await userEvent.click(screen.getByTestId(`dial-pad-button-1`)); diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx index a35b28763bda4..bb890ae5214b4 100644 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx @@ -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(function const { makeCall, closeDialer } = useVoipAPI(); const [number, setNumber] = useState(''); - const handleCall = () => { - makeCall(number); - closeDialer(); - }; + const handleCall = useDevicePermissionPrompt({ + actionType: 'outgoing', + onAccept: () => { + makeCall(number); + closeDialer(); + }, + }); return ( @@ -38,7 +42,7 @@ const VoipDialerView = forwardRef(function