Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
591b687
refactor DeviceContext and provider (update device list when permissi…
gabriellsh Jul 3, 2025
ac6d47a
Update GenericMenu prop types
gabriellsh Jul 3, 2025
34e1fa5
WIP: permission request mechanism
gabriellsh Jul 3, 2025
b374ddc
WIP: fix unnamed devices
gabriellsh Jul 3, 2025
4fc1c99
create new translation strings
gabriellsh Jul 3, 2025
de04b3e
Merge remote-tracking branch 'origin/develop' into voip/permissionFlow
gabriellsh Jul 8, 2025
815f0fb
Update translation strings
gabriellsh Jul 10, 2025
03d377b
allow to request device if permission is granted (firefox devicelist …
gabriellsh Jul 10, 2025
6d39397
Complete permission request flow
gabriellsh Jul 10, 2025
8f655fd
Fix duplicate device ids
gabriellsh Jul 10, 2025
0ffbbea
Fix default device not selected
gabriellsh Jul 10, 2025
a2aac1d
mark correct device as selected after prompting permission
gabriellsh Jul 10, 2025
bf0f516
ensure specific device permission is requested when changing devices
gabriellsh Jul 10, 2025
230a35d
fix button color for device change prompt modal
gabriellsh Jul 10, 2025
27adf91
Fix broken unit tests
gabriellsh Jul 14, 2025
252463b
fix broken stories
gabriellsh Jul 14, 2025
d1a1acf
unit test PermissionFlowModal
gabriellsh Jul 14, 2025
0ff8537
Add Jest to ui-contexts
gabriellsh Jul 14, 2025
c8efc95
Test `useMediaPermissions`
gabriellsh Jul 14, 2025
78f015c
Add tests for useDevicePermissionPrompt
gabriellsh Jul 14, 2025
a8cda68
Merge remote-tracking branch 'origin/develop' into voip/permissionFlow
gabriellsh Jul 14, 2025
58d8281
Add changeset
gabriellsh Jul 14, 2025
7a8c023
Merge branch 'develop' into voip/permissionFlow
gabriellsh Jul 16, 2025
47e70fc
invalidate permission query too (safari)
gabriellsh Jul 17, 2025
3019915
Fix header action tooltip
gabriellsh Jul 17, 2025
0b524d6
Merge branch 'develop' into voip/permissionFlow
gabriellsh Aug 12, 2025
e412f87
fix imports
gabriellsh Aug 13, 2025
64811f4
Merge branch 'develop' into voip/permissionFlow
gabriellsh Aug 13, 2025
5efe41a
Fix jest type version
gabriellsh Aug 14, 2025
6a10748
improve coverage
gabriellsh Aug 14, 2025
d35dc93
fix stest
gabriellsh Aug 14, 2025
660ad88
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into voip…
tassoevan Aug 20, 2025
3e91fdc
Merge branch 'develop' into voip/permissionFlow
kodiakhq[bot] Aug 20, 2025
4580374
Merge branch 'develop' into voip/permissionFlow
dougfabris Aug 22, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/weak-windows-doubt.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 18 additions & 13 deletions apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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();
});
Expand All @@ -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]);
};
150 changes: 102 additions & 48 deletions apps/meteor/client/providers/DeviceProvider/DeviceProvider.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<Device[]>([]);
const [availableAudioInputDevices, setAvailableAudioInputDevices] = useState<Device[]>([]);
const [selectedAudioOutputDevice, setSelectedAudioOutputDevice] = useState<Device>({
id: 'default',
const defaultDevices = {
audioInput: [],
audioOutput: [],
defaultAudioOutputDevice: {
id: '',
label: '',
type: 'audio',
});
const [selectedAudioInputDevice, setSelectedAudioInputDevice] = useState<Device>({
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<Device | undefined>(undefined);
const [selectedAudioInputDevice, setSelectedAudioInputDevice] = useState<Device | undefined>(undefined);

const setAudioInputDevice = (device: Device): void => {
if (!isSecureContext) {
Expand All @@ -45,62 +53,108 @@ 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) {
return {
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>;
};
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 32 additions & 23 deletions packages/mock-providers/src/MockedAppRootBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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>
Expand Down
Loading
Loading