Skip to content
157 changes: 0 additions & 157 deletions packages/ui-voip/src/components/DevicePicker.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ComponentProps } from 'react';
import { forwardRef } from 'react';

import ActionButton from '../ActionButton';

type DevicePickerButtonProps = {
secondary?: boolean;
small?: boolean;
} & Omit<ComponentProps<typeof ActionButton>, 'label' | 'icon'>;

// GenericMenu for some reason passes `small: true` when the button is disabled (??)
// so this is just a wrapper to stop that from happening.
const DevicePickerButton = forwardRef<HTMLButtonElement, DevicePickerButtonProps>(function DevicePickerButton(
{ secondary = false, small: _small, ...props },
ref,
) {
return <ActionButton secondary={secondary} {...props} label='customize' icon='customize' ref={ref} />;
});

export default DevicePickerButton;
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Box, RadioButton } from '@rocket.chat/fuselage';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';

export const getDefaultDeviceItem = (label: string, type: 'input' | 'output'): GenericMenuItemProps => ({
content: (
<Box is='span' title={label} fontSize={14}>
{label}
</Box>
),
addon: <RadioButton onChange={() => undefined} checked={true} disabled />,
id: `default-${type}`,
});
142 changes: 142 additions & 0 deletions packages/ui-voip/src/components/DevicePicker/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Box, RadioButton } from '@rocket.chat/fuselage';
import { useSafely } from '@rocket.chat/fuselage-hooks';
import { GenericMenu } from '@rocket.chat/ui-client';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useAvailableDevices, useSelectedDevices } from '@rocket.chat/ui-contexts';
import type { MouseEvent } from 'react';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { useMediaCallContext } from '../context';
import { useDevicePermissionPrompt2, stopTracks } from '../hooks/useDevicePermissionPrompt';
import DevicePickerButton from './DevicePickerButton';
import { getDefaultDeviceItem } from './getDefaultDeviceItem';

const DevicePicker = ({ secondary = false }: { secondary?: boolean }) => {
const { t } = useTranslation();

const { onDeviceChange } = useMediaCallContext();

const availableDevices = useAvailableDevices();
const selectedAudioDevices = useSelectedDevices();

const availableInputDevice =
availableDevices?.audioInput?.map<GenericMenuItemProps>((device) => {
// Only use default item when device.id is actually missing
if (!device.id) {
return getDefaultDeviceItem(t('Default'), 'input');
}

return {
id: `${device.id}-input`,
content: (
<Box is='span' title={device.label || t('Default')} fontSize={14}>
{device.label || t('Default')}
</Box>
),
addon: <RadioButton checked={device.id === selectedAudioDevices?.audioInput?.id} />,
};
}) || [];

const availableOutputDevice =
availableDevices?.audioOutput?.map<GenericMenuItemProps>((device) => {
// Only use default item when device.id is actually missing
if (!device.id) {
return getDefaultDeviceItem(t('Default'), 'output');
}

return {
id: `${device.id}-output`,
content: (
<Box is='span' title={device.label || t('Default')} fontSize={14}>
{device.label || t('Default')}
</Box>
),
addon: <RadioButton checked={device.id === selectedAudioDevices?.audioOutput?.id} />,
onClick(e?: MouseEvent<HTMLElement>) {
e?.preventDefault();
e?.stopPropagation();
},
};
}) || [];

const micSection = {
title: t('Microphone'),
items: availableInputDevice,
};

const speakerSection = {
title: t('Speaker'),
items: availableOutputDevice,
};

const disabled = availableOutputDevice.length === 0 && availableInputDevice.length === 0;

const [isOpen, setIsOpen] = useSafely(useState(false));

const requestPermission = useDevicePermissionPrompt2();

const onOpenChange = useCallback(
(isOpen: boolean) => {
if (!isOpen) {
setIsOpen(false);
return;
}

void requestPermission({
actionType: 'device-change',
})
.then((stream) => {
stopTracks(stream);
setIsOpen(true);
})
.catch((error) => {
// Permission denied or error occurred, keep menu closed
console.warn('DevicePicker: Failed to request device permissions', error);
setIsOpen(false);
});
},
[requestPermission, setIsOpen],
);

return (
<GenericMenu
title={disabled ? t('Device_settings_not_supported_by_browser') : t('Device_settings_lowercase')}
sections={[micSection, speakerSection]}
disabled={disabled}
placement='top-end'
selectionMode='multiple'
isOpen={isOpen}
onOpenChange={onOpenChange}
onAction={(deviceId) => {
if (typeof deviceId !== 'string') {
return;
}

// Use endsWith to check suffix and slice to remove it
if (deviceId.endsWith('-input')) {
const id = deviceId.slice(0, -6); // Remove '-input' suffix
const device = availableDevices?.audioInput?.find((device) => device.id === id);
if (device) {
onDeviceChange(device);
}
return;
}

if (deviceId.endsWith('-output')) {
const id = deviceId.slice(0, -7); // Remove '-output' suffix
const device = availableDevices?.audioOutput?.find((device) => device.id === id);
if (device) {
onDeviceChange(device);
}
return;
}

console.warn('Device Picker - Failed to select device: Invalid deviceId', deviceId);
}}
button={<DevicePickerButton secondary={secondary} tiny={!secondary} />}
/>
);
};

export default DevicePicker;
2 changes: 1 addition & 1 deletion packages/ui-voip/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ export { useKeypad } from './Keypad/useKeypad';
export { useInfoSlots } from './PeerInfo/useInfoSlots';
export { default as PeerAutocomplete } from './PeerAutocomplete';
export { default as Timer } from './Timer';
export { default as DevicePicker } from './DevicePicker';
export { default as DevicePicker } from './DevicePicker/index';
export { default as CallHistoryInternalUser } from './CallHistoryInternalUser';
export { default as CallHistoryExternalUser } from './CallHistoryExternalUser';