diff --git a/apps/meteor/client/NavBarV2/NavBarControls/NavBarControlsMenu.tsx b/apps/meteor/client/NavBarV2/NavBarControls/NavBarControlsMenu.tsx index 8c92510d88fcf..7b7284336d818 100644 --- a/apps/meteor/client/NavBarV2/NavBarControls/NavBarControlsMenu.tsx +++ b/apps/meteor/client/NavBarV2/NavBarControls/NavBarControlsMenu.tsx @@ -1,28 +1,21 @@ import { NavBarItem } from '@rocket.chat/fuselage'; import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { GenericMenu } from '@rocket.chat/ui-client'; -import { useVoipState } from '@rocket.chat/ui-voip'; import type { HTMLAttributes } from 'react'; import { useTranslation } from 'react-i18next'; import { useOmnichannelEnabled } from '../../hooks/omnichannel/useOmnichannelEnabled'; type NavBarControlsMenuProps = Omit, 'is'> & { - voipItems: GenericMenuItemProps[]; omnichannelItems: GenericMenuItemProps[]; isPressed: boolean; }; -const NavBarControlsMenu = ({ voipItems, omnichannelItems, isPressed, ...props }: NavBarControlsMenuProps) => { +const NavBarControlsMenu = ({ omnichannelItems, isPressed, ...props }: NavBarControlsMenuProps) => { const { t } = useTranslation(); - const { isEnabled: showVoip } = useVoipState(); const showOmnichannel = useOmnichannelEnabled(); const sections = [ - { - title: t('Voice_Call'), - items: showVoip ? voipItems : [], - }, { title: t('Omnichannel'), items: showOmnichannel ? omnichannelItems : [], diff --git a/apps/meteor/client/NavBarV2/NavBarControls/NavBarControlsWithCall.tsx b/apps/meteor/client/NavBarV2/NavBarControls/NavBarControlsWithCall.tsx index 716279688c679..0f5e370a4a4c0 100644 --- a/apps/meteor/client/NavBarV2/NavBarControls/NavBarControlsWithCall.tsx +++ b/apps/meteor/client/NavBarV2/NavBarControls/NavBarControlsWithCall.tsx @@ -6,12 +6,11 @@ import { useOmnichannelCallDialPadAction } from '../NavBarOmnichannelGroup/hooks import { useOmnichannelCallToggleAction } from '../NavBarOmnichannelGroup/hooks/useOmnichannelCallToggleAction'; type NavBarControlsMenuProps = Omit, 'is'> & { - voipItems: GenericMenuItemProps[]; omnichannelItems: GenericMenuItemProps[]; isPressed: boolean; }; -const NavBarControlsWithCall = ({ voipItems, omnichannelItems, isPressed, ...props }: NavBarControlsMenuProps) => { +const NavBarControlsWithCall = ({ omnichannelItems, isPressed, ...props }: NavBarControlsMenuProps) => { const { icon: omnichannelCallIcon, title: omnichannelCallTitle, @@ -44,7 +43,7 @@ const NavBarControlsWithCall = ({ voipItems, omnichannelItems, isPressed, ...pro }, ] as GenericMenuItemProps[]; - return ; + return ; }; export default NavBarControlsWithCall; diff --git a/apps/meteor/client/NavBarV2/NavBarControls/NavBarControlsWithData.tsx b/apps/meteor/client/NavBarV2/NavBarControls/NavBarControlsWithData.tsx index ce4c7a9a3081f..4630a4e4291dc 100644 --- a/apps/meteor/client/NavBarV2/NavBarControls/NavBarControlsWithData.tsx +++ b/apps/meteor/client/NavBarV2/NavBarControls/NavBarControlsWithData.tsx @@ -7,17 +7,12 @@ import { useIsCallEnabled } from '../../contexts/CallContext'; import { useOmnichannelContactAction } from '../NavBarOmnichannelGroup/hooks/useOmnichannelContactAction'; import { useOmnichannelLivechatToggle } from '../NavBarOmnichannelGroup/hooks/useOmnichannelLivechatToggle'; import { useOmnichannelQueueAction } from '../NavBarOmnichannelGroup/hooks/useOmnichannelQueueAction'; -import { useVoipDialerAction } from '../NavBarVoipGroup/hooks/useVoipDialerAction'; -import { useVoipTogglerAction } from '../NavBarVoipGroup/hooks/useVoipTogglerAction'; type NavBarControlsMenuProps = Omit, 'is'>; const NavBarControlsWithData = (props: NavBarControlsMenuProps) => { const isCallEnabled = useIsCallEnabled(); - const { title: dialerTitle, handleToggleDialer, isPressed: isVoipDialerPressed, isDisabled: dialerDisabled } = useVoipDialerAction(); - const { isRegistered, title: togglerTitle, handleToggleVoip, isDisabled: togglerDisabled } = useVoipTogglerAction(); - const { isEnabled: queueEnabled, icon: queueIcon, @@ -39,30 +34,12 @@ const NavBarControlsWithData = (props: NavBarControlsMenuProps) => { handleAvailableStatusChange, } = useOmnichannelLivechatToggle(); - const voipItems = [ - { - id: 'voipDialer', - icon: 'dialpad', - content: dialerTitle, - onClick: handleToggleDialer, - disabled: dialerDisabled, - }, - { - id: 'voipToggler', - icon: isRegistered ? 'phone-disabled' : 'phone', - content: togglerTitle, - onClick: handleToggleVoip, - disabled: togglerDisabled, - }, - ].filter(Boolean) as GenericMenuItemProps[]; - const omnichannelItems = [ queueEnabled && { id: 'omnichannelQueue', icon: queueIcon, content: queueTitle, onClick: handleGoToQueue, - disabled: dialerDisabled, }, { id: 'omnichannelContact', @@ -78,13 +55,13 @@ const NavBarControlsWithData = (props: NavBarControlsMenuProps) => { }, ].filter(Boolean) as GenericMenuItemProps[]; - const isPressed = isVoipDialerPressed || isQueuePressed || isContactPressed; + const isPressed = isQueuePressed || isContactPressed; if (isCallEnabled) { - return ; + return ; } - return ; + return ; }; export default NavBarControlsWithData; diff --git a/apps/meteor/client/NavBarV2/NavBarVoipGroup/NavBarItemVoipToggler.tsx b/apps/meteor/client/NavBarV2/NavBarVoipGroup/NavBarItemVoipToggler.tsx deleted file mode 100644 index d4b508709f40b..0000000000000 --- a/apps/meteor/client/NavBarV2/NavBarVoipGroup/NavBarItemVoipToggler.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { NavBarItem } from '@rocket.chat/fuselage'; -import type { HTMLAttributes } from 'react'; - -import { useVoipTogglerAction } from './hooks/useVoipTogglerAction'; - -type NavBarItemVoipDialerProps = Omit, 'is'> & { - primary?: boolean; -}; - -const NavBarItemVoipToggler = (props: NavBarItemVoipDialerProps) => { - const { title, icon, isDisabled, handleToggleVoip } = useVoipTogglerAction(); - - return ; -}; - -export default NavBarItemVoipToggler; diff --git a/apps/meteor/client/NavBarV2/NavBarVoipGroup/hooks/useVoipDialerAction.ts b/apps/meteor/client/NavBarV2/NavBarVoipGroup/hooks/useVoipDialerAction.ts deleted file mode 100644 index 765c6af619d79..0000000000000 --- a/apps/meteor/client/NavBarV2/NavBarVoipGroup/hooks/useVoipDialerAction.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { useLayout } from '@rocket.chat/ui-contexts'; -import { useVoipDialer, useVoipState } from '@rocket.chat/ui-voip'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -export const useVoipDialerAction = () => { - const { t } = useTranslation(); - const { sidebar } = useLayout(); - const { clientError, isReady, isRegistered } = useVoipState(); - const { open: isDialerOpen, openDialer, closeDialer } = useVoipDialer(); - - const handleToggleDialer = useEffectEvent(() => { - sidebar.toggle(); - isDialerOpen ? closeDialer() : openDialer(); - }); - - const title = useMemo(() => { - if (!isReady && !clientError) { - return t('Loading'); - } - - if (!isRegistered || clientError) { - return t('Voice_calling_disabled'); - } - - return t('New_Call'); - }, [clientError, isReady, isRegistered, t]); - - return { handleToggleDialer, title, isPressed: isDialerOpen, isDisabled: !isReady || !isRegistered }; -}; diff --git a/apps/meteor/client/NavBarV2/NavBarVoipGroup/hooks/useVoipTogglerAction.ts b/apps/meteor/client/NavBarV2/NavBarVoipGroup/hooks/useVoipTogglerAction.ts deleted file mode 100644 index b60e13804644e..0000000000000 --- a/apps/meteor/client/NavBarV2/NavBarVoipGroup/hooks/useVoipTogglerAction.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Keys } from '@rocket.chat/icons'; -import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; -import { useVoipAPI, useVoipState } from '@rocket.chat/ui-voip'; -import { useMutation } from '@tanstack/react-query'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -export const useVoipTogglerAction = () => { - const { t } = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - - const { clientError, isReady, isRegistered, isReconnecting } = useVoipState(); - const { register, unregister, onRegisteredOnce, onUnregisteredOnce } = useVoipAPI(); - - const toggleVoip = useMutation({ - mutationFn: async () => { - if (!isRegistered) { - await register(); - return true; - } - - await unregister(); - return false; - }, - onSuccess: (isEnabled: boolean) => { - if (isEnabled) { - onRegisteredOnce(() => dispatchToastMessage({ type: 'success', message: t('Voice_calling_enabled') })); - } else { - onUnregisteredOnce(() => dispatchToastMessage({ type: 'success', message: t('Voice_calling_disabled') })); - } - }, - onError: () => { - dispatchToastMessage({ type: 'error', message: t('Voice_calling_registration_failed') }); - }, - }); - - const title = useMemo(() => { - if (clientError) { - return t(clientError.message); - } - - if (!isReady || toggleVoip.isPending) { - return t('Loading'); - } - - if (isReconnecting) { - return t('Reconnecting'); - } - - return isRegistered ? t('Disable_voice_calling') : t('Enable_voice_calling'); - }, [clientError, isRegistered, isReconnecting, isReady, toggleVoip.isPending, t]); - - return { - handleToggleVoip: () => toggleVoip.mutate(), - title, - icon: (isRegistered ? 'phone' : 'phone-disabled') as Keys, - isRegistered, - isDisabled: !isReady || toggleVoip.isPending || isReconnecting, - }; -}; diff --git a/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx b/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx deleted file mode 100644 index a9920688b59cf..0000000000000 --- a/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { useMediaDeviceMicrophonePermission, usePermission, useUserId } from '@rocket.chat/ui-contexts'; -import { useVoipAPI, useVoipState, useDevicePermissionPrompt } from '@rocket.chat/ui-voip'; -import { useMemo } from 'react'; - -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 { uids = [] } = useRoom(); - const ownUserId = useUserId(); - const canStartVoiceCall = usePermission('view-user-voip-extension'); - const dispatchWarning = useVoipWarningModal(); - - const { state: micPermissionState } = useMediaDeviceMicrophonePermission(); - - const isMicPermissionDenied = micPermissionState === 'denied'; - - const { isEnabled, isRegistered, isInCall } = useVoipState(); - const { makeCall } = useVoipAPI(); - - const members = useMemo(() => uids.filter((uid) => uid !== ownUserId), [uids, ownUserId]); - const remoteUserId = members[0]; - - const { data: { user: remoteUser } = {}, isPending } = useUserInfoQuery({ userId: remoteUserId }, { enabled: Boolean(remoteUserId) }); - - const isRemoteRegistered = !!remoteUser?.freeSwitchExtension; - const isDM = members.length === 1; - - const disabled = isMicPermissionDenied || !isDM || isInCall || isPending; - const allowed = canStartVoiceCall && isDM && !isInCall && !isPending; - const canMakeVoipCall = allowed && isRemoteRegistered && isRegistered && isEnabled && !isMicPermissionDenied; - - const tooltip = useMemo(() => { - if (isMicPermissionDenied) { - return 'Microphone_access_not_allowed'; - } - - if (isInCall) { - return 'Unable_to_make_calls_while_another_is_ongoing'; - } - - 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 promptPermission(); - } - dispatchWarning(); - }); - - return useMemo((): RoomToolboxActionConfig | undefined => { - if (!allowed) { - return undefined; - } - - return { - id: 'start-voice-call', - title: tooltip || 'Voice_Call', - icon: 'phone', - featured: true, - action: handleOnClick, - groups: ['direct'] as const, - order: 2, - disabled, - }; - }, [allowed, disabled, handleOnClick, tooltip]); -}; diff --git a/apps/meteor/client/sidebar/header/hooks/useVoipItemsSection.tsx b/apps/meteor/client/sidebar/header/hooks/useVoipItemsSection.tsx deleted file mode 100644 index d77a78deb457c..0000000000000 --- a/apps/meteor/client/sidebar/header/hooks/useVoipItemsSection.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Box } from '@rocket.chat/fuselage'; -import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; -import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; -import { useVoipAPI, useVoipState } from '@rocket.chat/ui-voip'; -import { useMutation } from '@tanstack/react-query'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -export const useVoipItemsSection = (): { items: GenericMenuItemProps[] } | undefined => { - const { t } = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - - const { clientError, isEnabled, isReady, isRegistered, isReconnecting } = useVoipState(); - const { register, unregister, onRegisteredOnce, onUnregisteredOnce } = useVoipAPI(); - - const toggleVoip = useMutation({ - mutationFn: async () => { - if (!isRegistered) { - await register(); - return true; - } - - await unregister(); - return false; - }, - onSuccess: (isEnabled: boolean) => { - if (isEnabled) { - onRegisteredOnce(() => dispatchToastMessage({ type: 'success', message: t('Voice_calling_enabled') })); - } else { - onUnregisteredOnce(() => dispatchToastMessage({ type: 'success', message: t('Voice_calling_disabled') })); - } - }, - onError: () => { - dispatchToastMessage({ type: 'error', message: t('Voice_calling_registration_failed') }); - }, - }); - - const tooltip = useMemo(() => { - if (clientError) { - return t(clientError.message); - } - - if (!isReady || toggleVoip.isPending) { - return t('Loading'); - } - - if (isReconnecting) { - return t('Reconnecting'); - } - - return ''; - }, [clientError, isReady, toggleVoip.isPending, t, isReconnecting]); - - return useMemo(() => { - if (!isEnabled) { - return; - } - - return { - items: [ - { - id: 'toggle-voip', - icon: isRegistered ? 'phone-disabled' : 'phone', - disabled: !isReady || toggleVoip.isPending || isReconnecting, - onClick: () => toggleVoip.mutate(), - content: ( - - {isRegistered ? t('Disable_voice_calling') : t('Enable_voice_calling')} - - ), - }, - ], - }; - }, [isEnabled, isRegistered, isReady, tooltip, t, toggleVoip, isReconnecting]); -}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx deleted file mode 100644 index 9684c2065d296..0000000000000 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import { useUserId } from '@rocket.chat/ui-contexts'; -import { useVoipAPI, useVoipState } from '@rocket.chat/ui-voip'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { useMediaPermissions } from '../../../composer/messageBox/hooks/useMediaPermissions'; -import { useUserCard } from '../../../contexts/UserCardContext'; -import type { UserInfoAction } from '../useUserInfoActions'; - -export const useVoipCallAction = (user: Pick): UserInfoAction | undefined => { - const { t } = useTranslation(); - const { closeUserCard } = useUserCard(); - const ownUserId = useUserId(); - - const { isEnabled, isRegistered, isInCall } = useVoipState(); - const { makeCall } = useVoipAPI(); - const [isMicPermissionDenied] = useMediaPermissions('microphone'); - - const isRemoteRegistered = !!user?.freeSwitchExtension; - const isSameUser = ownUserId === user._id; - - const disabled = isSameUser || isMicPermissionDenied || !isRemoteRegistered || !isRegistered || isInCall; - - const voipCallOption = useMemo(() => { - const handleClick = () => { - makeCall(user?.freeSwitchExtension as string); - closeUserCard(); - }; - - return isEnabled && !isSameUser - ? { - type: 'communication', - title: t('Voice_call'), - icon: 'phone', - disabled, - onClick: handleClick, - } - : undefined; - }, [closeUserCard, disabled, isEnabled, isSameUser, makeCall, t, user?.freeSwitchExtension]); - - return voipCallOption; -}; diff --git a/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.spec.tsx b/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.spec.tsx deleted file mode 100644 index ecd8110cd4ee1..0000000000000 --- a/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.spec.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { composeStories } from '@storybook/react'; -import { render } from '@testing-library/react'; -import { axe } from 'jest-axe'; - -import * as stories from './VoipActionButton.stories'; - -const testCases = Object.values(composeStories(stories)).map((story) => [story.storyName || 'Story', story]); - -test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { - const tree = render(); - expect(tree.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/VoipActionButton/VoipActionButton.stories.tsx b/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.stories.tsx deleted file mode 100644 index d16c1f40ed176..0000000000000 --- a/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.stories.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { Meta, StoryFn } from '@storybook/react'; - -import VoipActionButton from './VoipActionButton'; - -export default { - title: 'Components/VoipActionButton', - component: VoipActionButton, -} satisfies Meta; - -export const SuccessButton: StoryFn = () => { - return ; -}; - -export const DangerButton: StoryFn = () => { - return ; -}; - -export const NeutralButton: StoryFn = () => { - return ; -}; diff --git a/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.tsx b/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.tsx deleted file mode 100644 index 249582107b5f5..0000000000000 --- a/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Icon, IconButton } from '@rocket.chat/fuselage'; -import type { Keys } from '@rocket.chat/icons'; -import type { ComponentPropsWithoutRef } from 'react'; - -type ActionButtonProps = Pick, 'className' | 'disabled' | 'pressed' | 'danger' | 'success'> & { - label: string; - icon: Keys; - onClick?: () => void; -}; - -const VoipActionButton = ({ disabled, label, pressed, icon, danger, success, className, onClick }: ActionButtonProps) => ( - } - title={label} - pressed={pressed} - aria-label={label} - disabled={disabled} - onClick={() => onClick?.()} - /> -); - -export default VoipActionButton; diff --git a/packages/ui-voip/src/components/VoipActionButton/__snapshots__/VoipActionButton.spec.tsx.snap b/packages/ui-voip/src/components/VoipActionButton/__snapshots__/VoipActionButton.spec.tsx.snap deleted file mode 100644 index 516c82d68465b..0000000000000 --- a/packages/ui-voip/src/components/VoipActionButton/__snapshots__/VoipActionButton.spec.tsx.snap +++ /dev/null @@ -1,61 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders DangerButton without crashing 1`] = ` - -
- -
- -`; - -exports[`renders NeutralButton without crashing 1`] = ` - -
- -
- -`; - -exports[`renders SuccessButton without crashing 1`] = ` - -
- -
- -`; diff --git a/packages/ui-voip/src/components/VoipActionButton/index.ts b/packages/ui-voip/src/components/VoipActionButton/index.ts deleted file mode 100644 index 3e6352647f2f6..0000000000000 --- a/packages/ui-voip/src/components/VoipActionButton/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './VoipActionButton'; diff --git a/packages/ui-voip/src/components/VoipActions/VoipActions.spec.tsx b/packages/ui-voip/src/components/VoipActions/VoipActions.spec.tsx deleted file mode 100644 index d1f52f979e4e3..0000000000000 --- a/packages/ui-voip/src/components/VoipActions/VoipActions.spec.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { composeStories } from '@storybook/react'; -import { render } from '@testing-library/react'; -import { axe } from 'jest-axe'; - -import * as stories from './VoipActions.stories'; - -const testCases = Object.values(composeStories(stories)).map((story) => [story.storyName || 'Story', story]); - -test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { - const tree = render(, { wrapper: mockAppRoot().build() }); - expect(tree.baseElement).toMatchSnapshot(); -}); - -test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { - const { container } = render(, { wrapper: mockAppRoot().build() }); - - const results = await axe(container); - expect(results).toHaveNoViolations(); -}); diff --git a/packages/ui-voip/src/components/VoipActions/VoipActions.stories.tsx b/packages/ui-voip/src/components/VoipActions/VoipActions.stories.tsx deleted file mode 100644 index 622b4bbc25473..0000000000000 --- a/packages/ui-voip/src/components/VoipActions/VoipActions.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import type { Meta, StoryFn } from '@storybook/react'; - -import VoipActions from './VoipActions'; - -const noop = () => undefined; - -export default { - title: 'Components/VoipActions', - component: VoipActions, - decorators: [ - mockAppRoot() - .withMicrophonePermissionState({ state: 'granted' } as PermissionStatus) - .buildStoryDecorator(), - ], -} satisfies Meta; - -export const IncomingActions: StoryFn = () => { - return ; -}; - -export const OngoingActions: StoryFn = () => { - return ; -}; - -export const OutgoingActions: StoryFn = () => { - return ; -}; diff --git a/packages/ui-voip/src/components/VoipActions/VoipActions.tsx b/packages/ui-voip/src/components/VoipActions/VoipActions.tsx deleted file mode 100644 index d17a68791a8d9..0000000000000 --- a/packages/ui-voip/src/components/VoipActions/VoipActions.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { ButtonGroup } from '@rocket.chat/fuselage'; -import { useTranslation } from 'react-i18next'; - -import { useDevicePermissionPrompt } from '../../hooks/useDevicePermissionPrompt'; -import ActionButton from '../VoipActionButton'; - -type VoipGenericActionsProps = { - isDTMFActive?: boolean; - isTransferActive?: boolean; - isMuted?: boolean; - isHeld?: boolean; - onDTMF?: () => void; - onTransfer?: () => void; - onMute?: (muted: boolean) => void; - onHold?: (held: boolean) => void; -}; - -type VoipIncomingActionsProps = VoipGenericActionsProps & { - onEndCall?: never; - onDecline: () => void; - onAccept: () => void; -}; - -type VoipOngoingActionsProps = VoipGenericActionsProps & { - onDecline?: never; - onAccept?: never; - onEndCall: () => void; -}; - -type VoipActionsProps = VoipIncomingActionsProps | VoipOngoingActionsProps; - -const isIncoming = (props: VoipActionsProps): props is VoipIncomingActionsProps => - 'onDecline' in props && 'onAccept' in props && !('onEndCall' in props); - -const isOngoing = (props: VoipActionsProps): props is VoipOngoingActionsProps => - 'onEndCall' in props && !('onAccept' in props && 'onDecline' in props); - -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) && } - - events.onMute?.(!isMuted)} - /> - - {!isIncoming(events) && ( - events.onHold?.(!isHeld)} - /> - )} - - - - - - {isOngoing(events) && } - - {isIncoming(events) && } - - ); -}; - -export default VoipActions; diff --git a/packages/ui-voip/src/components/VoipActions/__snapshots__/VoipActions.spec.tsx.snap b/packages/ui-voip/src/components/VoipActions/__snapshots__/VoipActions.spec.tsx.snap deleted file mode 100644 index f6d53ff7b8872..0000000000000 --- a/packages/ui-voip/src/components/VoipActions/__snapshots__/VoipActions.spec.tsx.snap +++ /dev/null @@ -1,239 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders IncomingActions without crashing 1`] = ` - -
-
- - - - - -
-
- -`; - -exports[`renders OngoingActions without crashing 1`] = ` - -
-
- - - - - -
-
- -`; - -exports[`renders OutgoingActions without crashing 1`] = ` - -
-
- - - - - -
-
- -`; diff --git a/packages/ui-voip/src/components/VoipActions/index.ts b/packages/ui-voip/src/components/VoipActions/index.ts deleted file mode 100644 index fb8ae6407ce32..0000000000000 --- a/packages/ui-voip/src/components/VoipActions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './VoipActions'; diff --git a/packages/ui-voip/src/components/VoipContactId/VoipContactId.spec.tsx b/packages/ui-voip/src/components/VoipContactId/VoipContactId.spec.tsx deleted file mode 100644 index f25fe0ace4054..0000000000000 --- a/packages/ui-voip/src/components/VoipContactId/VoipContactId.spec.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { composeStories } from '@storybook/react'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { axe } from 'jest-axe'; - -import VoipContactId from './VoipContactId'; -import * as stories from './VoipContactId.stories'; - -const testCases = Object.values(composeStories(stories)).map((story) => [story.storyName || 'Story', story]); - -beforeAll(() => { - Object.assign(navigator, { - clipboard: { - writeText: jest.fn(), - }, - }); -}); - -test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { - const tree = render(, { wrapper: mockAppRoot().build() }); - expect(tree.baseElement).toMatchSnapshot(); -}); - -test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { - const { container } = render(, { wrapper: mockAppRoot().build() }); - - const results = await axe(container); - expect(results).toHaveNoViolations(); -}); - -it('should display avatar and name when username is available', () => { - render(, { - wrapper: mockAppRoot().build(), - }); - - expect(screen.getByRole('presentation', { hidden: true })).toHaveAttribute('title', 'john.doe'); - expect(screen.getByText('John Doe')).toBeInTheDocument(); -}); - -it('should display transferedBy information when available', () => { - render(, { - wrapper: mockAppRoot().build(), - }); - - expect(screen.getByText('From: Jane Doe')).toBeInTheDocument(); -}); - -it('should display copy button when username isnt available', async () => { - const phone = faker.phone.number(); - render(, { - wrapper: mockAppRoot().build(), - }); - - const copyButton = screen.getByRole('button', { name: 'Copy_phone_number' }); - expect(copyButton).toBeInTheDocument(); - - await userEvent.click(copyButton); - await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith(phone)); -}); diff --git a/packages/ui-voip/src/components/VoipContactId/VoipContactId.stories.tsx b/packages/ui-voip/src/components/VoipContactId/VoipContactId.stories.tsx deleted file mode 100644 index 361c29b5de6aa..0000000000000 --- a/packages/ui-voip/src/components/VoipContactId/VoipContactId.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Box } from '@rocket.chat/fuselage'; -import type { Meta, StoryFn } from '@storybook/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; - -import VoipContactId from './VoipContactId'; - -export default { - title: 'Components/VoipContactId', - component: VoipContactId, - decorators: [ - (Story) => ( - - - - - - ), - ], -} satisfies Meta; - -export const Loading: StoryFn = () => { - return ; -}; - -export const WithUsername: StoryFn = () => { - return ; -}; - -export const WithTransfer: StoryFn = () => { - return ; -}; - -export const WithPhoneNumber: StoryFn = () => { - return ; -}; diff --git a/packages/ui-voip/src/components/VoipContactId/VoipContactId.tsx b/packages/ui-voip/src/components/VoipContactId/VoipContactId.tsx deleted file mode 100644 index 1152309a14da0..0000000000000 --- a/packages/ui-voip/src/components/VoipContactId/VoipContactId.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Box, IconButton, Skeleton } from '@rocket.chat/fuselage'; -import { UserAvatar } from '@rocket.chat/ui-avatar'; -import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; -import { useMutation } from '@tanstack/react-query'; -import { useTranslation } from 'react-i18next'; - -const VoipContactId = ({ - name, - username, - transferedBy, - isLoading = false, -}: { - name?: string; - username?: string; - transferedBy?: string; - isLoading?: boolean; -}) => { - const dispatchToastMessage = useToastMessageDispatch(); - const { t } = useTranslation(); - - const handleCopy = useMutation({ - mutationFn: (contactName: string) => navigator.clipboard.writeText(contactName), - onSuccess: () => dispatchToastMessage({ type: 'success', message: t('Phone_number_copied') }), - onError: () => dispatchToastMessage({ type: 'error', message: t('Failed_to_copy_phone_number') }), - }); - - if (!name) { - return null; - } - - if (isLoading) { - return ( - - - - - ); - } - - return ( - - {transferedBy && ( - - {t('From')}: {transferedBy} - - )} - - - {username && ( - - - - )} - - - {name} - - - {!username && ( - handleCopy.mutate(name)} - /> - )} - - - ); -}; - -export default VoipContactId; diff --git a/packages/ui-voip/src/components/VoipContactId/__snapshots__/VoipContactId.spec.tsx.snap b/packages/ui-voip/src/components/VoipContactId/__snapshots__/VoipContactId.spec.tsx.snap deleted file mode 100644 index a36412c73ccea..0000000000000 --- a/packages/ui-voip/src/components/VoipContactId/__snapshots__/VoipContactId.spec.tsx.snap +++ /dev/null @@ -1,149 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders Loading without crashing 1`] = ` - -
-
-
- - -
-
-
- -`; - -exports[`renders WithPhoneNumber without crashing 1`] = ` - -
-
-
-
-

- +554788765522 -

- -
-
-
-
- -`; - -exports[`renders WithTransfer without crashing 1`] = ` - -
-
-
-
- From - : - Jane Doe -
-
-
-
- -
-
-

- John Doe -

-
-
-
-
- -`; - -exports[`renders WithUsername without crashing 1`] = ` - -
-
-
-
-
-
- -
-
-

- John Doe -

-
-
-
-
- -`; diff --git a/packages/ui-voip/src/components/VoipContactId/index.ts b/packages/ui-voip/src/components/VoipContactId/index.ts deleted file mode 100644 index 9a50854dfa73b..0000000000000 --- a/packages/ui-voip/src/components/VoipContactId/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './VoipContactId'; diff --git a/packages/ui-voip/src/components/VoipDialPad/VoipDialPad.spec.tsx b/packages/ui-voip/src/components/VoipDialPad/VoipDialPad.spec.tsx deleted file mode 100644 index 208257f3d4c11..0000000000000 --- a/packages/ui-voip/src/components/VoipDialPad/VoipDialPad.spec.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/* eslint-disable no-await-in-loop */ -import { installPointerEvent, triggerLongPress } from '@react-spectrum/test-utils'; -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import DialPad from './VoipDialPad'; - -const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - -installPointerEvent(); - -beforeEach(() => { - jest.useFakeTimers(); -}); - -afterEach(() => { - jest.clearAllTimers(); -}); - -it('should not be editable by default', async () => { - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByLabelText('Phone_number')).toHaveAttribute('readOnly'); -}); - -it('should enable input when editable', async () => { - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByLabelText('Phone_number')).not.toHaveAttribute('readOnly'); -}); - -it('should disable backspace button when input is empty', async () => { - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByTestId('dial-paid-input-backspace')).toBeDisabled(); -}); - -it('should enable backspace button when input has value', async () => { - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByTestId('dial-paid-input-backspace')).toBeEnabled(); -}); - -it('should remove last character when backspace is clicked', async () => { - const fn = jest.fn(); - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByLabelText('Phone_number')).toHaveValue('123'); - - await user.click(screen.getByTestId('dial-paid-input-backspace')); - - expect(fn).toHaveBeenCalledWith('12'); -}); - -it('should call onChange when number is clicked', async () => { - const fn = jest.fn(); - render(, { wrapper: mockAppRoot().build() }); - - for (const digit of ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']) { - await user.click(screen.getByTestId(`dial-pad-button-${digit}`)); - expect(fn).toHaveBeenCalledWith(`123${digit}`, digit); - } -}); - -it('should call onChange with + when 0 pressed and held', async () => { - const fn = jest.fn(); - render(, { wrapper: mockAppRoot().build() }); - - const button = screen.getByTestId('dial-pad-button-0'); - - await user.click(button); - - expect(fn).toHaveBeenCalledWith('1230', '0'); - - await triggerLongPress({ element: button, advanceTimer: (time = 800) => jest.advanceTimersByTime(time) }); - - expect(fn).toHaveBeenCalledWith('123+', '+'); -}); diff --git a/packages/ui-voip/src/components/VoipDialPad/VoipDialPad.stories.tsx b/packages/ui-voip/src/components/VoipDialPad/VoipDialPad.stories.tsx deleted file mode 100644 index e393a9c44f291..0000000000000 --- a/packages/ui-voip/src/components/VoipDialPad/VoipDialPad.stories.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Box } from '@rocket.chat/fuselage'; -import type { Meta, StoryFn } from '@storybook/react'; - -import VoipDialPad from './VoipDialPad'; - -export default { - title: 'Components/VoipDialPad', - component: VoipDialPad, - decorators: [ - (Story) => ( - - - - ), - ], -} satisfies Meta; - -export const DialPad: StoryFn = () => { - return undefined} />; -}; diff --git a/packages/ui-voip/src/components/VoipDialPad/VoipDialPad.tsx b/packages/ui-voip/src/components/VoipDialPad/VoipDialPad.tsx deleted file mode 100644 index 0dad0fb14d512..0000000000000 --- a/packages/ui-voip/src/components/VoipDialPad/VoipDialPad.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { css } from '@rocket.chat/css-in-js'; -import { Box } from '@rocket.chat/fuselage'; -import { FocusScope } from 'react-aria'; - -import DialPadButton from './components/VoipDialPadButton'; -import DialPadInput from './components/VoipDialPadInput'; - -type DialPadProps = { - value: string; - editable?: boolean; - longPress?: boolean; - onChange(value: string, digit?: string): void; -}; - -const DIGITS = [ - ['1', ''], - ['2', 'ABC'], - ['3', 'DEF'], - ['4', 'GHI'], - ['5', 'JKL'], - ['6', 'MNO'], - ['7', 'PQRS'], - ['8', 'TUV'], - ['9', 'WXYZ'], - ['*', ''], - ['0', '+', '+'], - ['#', ''], -]; - -const dialPadClassName = css` - display: flex; - justify-content: center; - flex-wrap: wrap; - padding: 8px 8px 12px; - - > button { - margin: 4px; - } -`; - -const VoipDialPad = ({ editable = false, value, longPress = true, onChange }: DialPadProps) => ( - - - - onChange(e.currentTarget.value)} - onBackpaceClick={() => onChange(value.slice(0, -1))} - /> - - - - {DIGITS.map(([primaryDigit, subDigit, longPressDigit]) => ( - onChange(`${value}${digit}`, digit)} - /> - ))} - - - -); - -export default VoipDialPad; diff --git a/packages/ui-voip/src/components/VoipDialPad/components/VoipDialPadButton.tsx b/packages/ui-voip/src/components/VoipDialPad/components/VoipDialPadButton.tsx deleted file mode 100644 index 2ef1bc6e1a4b7..0000000000000 --- a/packages/ui-voip/src/components/VoipDialPad/components/VoipDialPadButton.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { css } from '@rocket.chat/css-in-js'; -import { Box, Button } from '@rocket.chat/fuselage'; -import { mergeProps, useLongPress, usePress } from 'react-aria'; -import { useTranslation } from 'react-i18next'; - -type DialPadButtonProps = { - digit: string; - subDigit?: string; - longPressDigit?: string; - onClick: (digit: string) => void; -}; - -const dialPadButtonClass = css` - width: 52px; - height: 40px; - min-width: 52px; - padding: 4px; - - > .rcx-button--content { - display: flex; - flex-direction: column; - } -`; - -const VoipDialPadButton = ({ digit, subDigit, longPressDigit, onClick }: DialPadButtonProps) => { - const { t } = useTranslation(); - - const { longPressProps } = useLongPress({ - accessibilityDescription: `${t(`Long_press_to_do_x`, { action: longPressDigit })}`, - onLongPress: () => longPressDigit && onClick(longPressDigit), - }); - - const { pressProps } = usePress({ - onPress: () => onClick(digit), - }); - - return ( - - ); -}; - -export default VoipDialPadButton; diff --git a/packages/ui-voip/src/components/VoipDialPad/components/VoipDialPadInput.tsx b/packages/ui-voip/src/components/VoipDialPad/components/VoipDialPadInput.tsx deleted file mode 100644 index 9723451f7db7f..0000000000000 --- a/packages/ui-voip/src/components/VoipDialPad/components/VoipDialPadInput.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { css } from '@rocket.chat/css-in-js'; -import { IconButton, TextInput } from '@rocket.chat/fuselage'; -import type { FocusEvent, FormEvent } from 'react'; -import { useTranslation } from 'react-i18next'; - -type DialPadInputProps = { - value: string; - readOnly?: boolean; - onBackpaceClick?: () => void; - onChange: (e: FormEvent) => void; - onBlur?: (event: FocusEvent) => void; -}; - -const className = css` - padding-block: 6px; - min-height: 28px; - height: 28px; -`; - -const VoipDialPadInput = ({ readOnly, value, onChange, onBackpaceClick }: DialPadInputProps) => { - const { t } = useTranslation(); - - return ( - - } - onChange={onChange} - /> - ); -}; - -export default VoipDialPadInput; diff --git a/packages/ui-voip/src/components/VoipDialPad/index.ts b/packages/ui-voip/src/components/VoipDialPad/index.ts deleted file mode 100644 index 9c97cebe16151..0000000000000 --- a/packages/ui-voip/src/components/VoipDialPad/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './VoipDialPad'; diff --git a/packages/ui-voip/src/components/VoipPopup/VoipPopup.spec.tsx b/packages/ui-voip/src/components/VoipPopup/VoipPopup.spec.tsx deleted file mode 100644 index 8e814f4d484ef..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/VoipPopup.spec.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { composeStories } from '@storybook/react'; -import { render, screen } from '@testing-library/react'; -import { axe } from 'jest-axe'; - -import VoipPopup from './VoipPopup'; -import * as stories from './VoipPopup.stories'; -import { useVoipSession } from '../../hooks/useVoipSession'; -import { createMockVoipSession } from '../../tests/mocks'; -import { replaceReactAriaIds } from '../../tests/utils/replaceReactAriaIds'; - -jest.mock('../../hooks/useVoipSession', () => ({ - useVoipSession: jest.fn(), -})); - -jest.mock('../../hooks/useVoipDialer', () => ({ - useVoipDialer: jest.fn(() => ({ open: true, openDialer: () => undefined, closeDialer: () => undefined })), -})); - -const mockedUseVoipSession = jest.mocked(useVoipSession); - -it('should properly render incoming popup', async () => { - mockedUseVoipSession.mockImplementationOnce(() => createMockVoipSession({ type: 'INCOMING' })); - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByTestId('vc-popup-incoming')).toBeInTheDocument(); -}); - -it('should properly render ongoing popup', async () => { - mockedUseVoipSession.mockImplementationOnce(() => createMockVoipSession({ type: 'ONGOING' })); - - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByTestId('vc-popup-ongoing')).toBeInTheDocument(); -}); - -it('should properly render outgoing popup', async () => { - mockedUseVoipSession.mockImplementationOnce(() => createMockVoipSession({ type: 'OUTGOING' })); - - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByTestId('vc-popup-outgoing')).toBeInTheDocument(); -}); - -it('should properly render error popup', async () => { - mockedUseVoipSession.mockImplementationOnce(() => createMockVoipSession({ type: 'ERROR' })); - - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByTestId('vc-popup-error')).toBeInTheDocument(); -}); - -it('should properly render dialer popup', async () => { - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByTestId('vc-popup-dialer')).toBeInTheDocument(); -}); - -it('should prioritize session over dialer', async () => { - mockedUseVoipSession.mockImplementationOnce(() => createMockVoipSession({ type: 'INCOMING' })); - - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.queryByTestId('vc-popup-dialer')).not.toBeInTheDocument(); - expect(screen.getByTestId('vc-popup-incoming')).toBeInTheDocument(); -}); - -const testCases = Object.values(composeStories(stories)).map((story) => [story.storyName || 'Story', story]); - -test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { - const tree = render(, { wrapper: mockAppRoot().build() }); - expect(replaceReactAriaIds(tree.baseElement)).toMatchSnapshot(); -}); - -test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { - const { container } = render(, { wrapper: mockAppRoot().build() }); - - const results = await axe(container); - expect(results).toHaveNoViolations(); -}); diff --git a/packages/ui-voip/src/components/VoipPopup/VoipPopup.stories.tsx b/packages/ui-voip/src/components/VoipPopup/VoipPopup.stories.tsx deleted file mode 100644 index 6939e30fe0e79..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/VoipPopup.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import type { Meta, StoryFn } from '@storybook/react'; - -import VoipPopup from './VoipPopup'; -import { createMockVoipProviders } from '../../tests/mocks'; - -const [MockedProviders, voipClient] = createMockVoipProviders(); - -const appRoot = mockAppRoot().withMicrophonePermissionState({ state: 'granted' } as PermissionStatus); - -export default { - title: 'Components/VoipPopup', - component: VoipPopup, - decorators: [ - (Story) => ( - - - - ), - appRoot.buildStoryDecorator(), - ], -} satisfies Meta; - -export const IncomingCall: StoryFn = () => { - voipClient.setSessionType('INCOMING'); - return ; -}; - -export const OngoingCall: StoryFn = () => { - voipClient.setSessionType('ONGOING'); - return ; -}; - -export const OutgoingCall: StoryFn = () => { - voipClient.setSessionType('OUTGOING'); - return ; -}; - -export const ErrorCall: StoryFn = () => { - voipClient.setSessionType('ERROR'); - return ; -}; diff --git a/packages/ui-voip/src/components/VoipPopup/VoipPopup.tsx b/packages/ui-voip/src/components/VoipPopup/VoipPopup.tsx deleted file mode 100644 index 4a414032867d8..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/VoipPopup.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { forwardRef, Ref } from 'react'; - -import { isVoipErrorSession, isVoipIncomingSession, isVoipOngoingSession, isVoipOutgoingSession } from '../../definitions'; -import type { PositionOffsets } from './components/VoipPopupContainer'; -import DialerView from './views/VoipDialerView'; -import ErrorView from './views/VoipErrorView'; -import IncomingView from './views/VoipIncomingView'; -import OngoingView from './views/VoipOngoingView'; -import OutgoingView from './views/VoipOutgoingView'; -import { useVoipDialer } from '../../hooks/useVoipDialer'; -import { useVoipSession } from '../../hooks/useVoipSession'; - -type VoipPopupProps = { - position?: PositionOffsets; - dragHandleRef?: Ref; -}; - -const VoipPopup = forwardRef(({ position, ...props }, ref) => { - const session = useVoipSession(); - const { open: isDialerOpen } = useVoipDialer(); - - if (isVoipIncomingSession(session)) { - return ; - } - - if (isVoipOngoingSession(session)) { - return ; - } - - if (isVoipOutgoingSession(session)) { - return ; - } - - if (isVoipErrorSession(session)) { - return ; - } - - if (isDialerOpen) { - return ; - } - - return null; -}); - -VoipPopup.displayName = 'VoipPopup'; - -export default VoipPopup; diff --git a/packages/ui-voip/src/components/VoipPopup/__snapshots__/VoipPopup.spec.tsx.snap b/packages/ui-voip/src/components/VoipPopup/__snapshots__/VoipPopup.spec.tsx.snap deleted file mode 100644 index 5382cd4202bb9..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/__snapshots__/VoipPopup.spec.tsx.snap +++ /dev/null @@ -1,1645 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders ErrorCall without crashing 1`] = ` - -
-
- - -`; - -exports[`renders IncomingCall without crashing 1`] = ` - -
-
- - -`; - -exports[`renders OngoingCall without crashing 1`] = ` - -
-
- - -`; - -exports[`renders OutgoingCall without crashing 1`] = ` - -
-
- - -`; diff --git a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupContainer.tsx b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupContainer.tsx deleted file mode 100644 index aa0a33f89e0d8..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupContainer.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Palette } from '@rocket.chat/fuselage'; -import styled from '@rocket.chat/styled'; -import type { ReactNode, Ref } from 'react'; -import { forwardRef } from 'react'; -import { FocusScope } from 'react-aria'; - -import VoipPopupDragHandle from './VoipPopupDragHandle'; - -export type PositionOffsets = Partial<{ - top: number; - right: number; - bottom: number; - left: number; -}>; - -type ContainerProps = { - children: ReactNode; - position?: PositionOffsets; - dragHandleRef?: Ref; - ['data-testid']: string; -}; - -const Container = styled('article', ({ position: _position, ...props }: Pick) => props)` - position: fixed; - top: ${(p) => (p.position?.top !== undefined ? `${p.position.top}px` : 'initial')}; - right: ${(p) => (p.position?.right !== undefined ? `${p.position.right}px` : 'initial')}; - bottom: ${(p) => (p.position?.bottom !== undefined ? `${p.position.bottom}px` : 'initial')}; - left: ${(p) => (p.position?.left !== undefined ? `${p.position.left}px` : 'initial')}; - display: flex; - flex-direction: column; - width: 250px; - min-height: 128px; - border-radius: 4px; - border: 1px solid ${Palette.stroke['stroke-dark'].toString()}; - box-shadow: - 0px 0px 1px 0px ${Palette.shadow['shadow-elevation-2x'].toString()}, - 0px 0px 12px 0px ${Palette.shadow['shadow-elevation-2y'].toString()}; - background-color: ${Palette.surface['surface-tint'].toString()}; - z-index: 100; -`; - -const VoipPopupContainer = forwardRef(function VoipPopupContainer( - { children, position = { top: 0, left: 0 }, dragHandleRef, ...props }, - ref, -) { - return ( - - - - {children} - - - ); -}); - -export default VoipPopupContainer; diff --git a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupContent.tsx b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupContent.tsx deleted file mode 100644 index aecc43de074cd..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupContent.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Box } from '@rocket.chat/fuselage'; -import type { ReactNode } from 'react'; - -const VoipPopupContent = ({ children }: { children: ReactNode }) => ( - - {children} - -); - -export default VoipPopupContent; diff --git a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupDragHandle.tsx b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupDragHandle.tsx deleted file mode 100644 index 55f50e1c812f5..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupDragHandle.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { css } from '@rocket.chat/css-in-js'; -import { Box, Icon } from '@rocket.chat/fuselage'; -import { ComponentProps, forwardRef } from 'react'; - -const dragHandle = css` - cursor: grab; - - &:active { - cursor: grabbing; - } -`; - -const VoipPopupDragHandle = forwardRef>(function VoipPopupDragHandle(props, ref) { - return ( - - - - ); -}); - -export default VoipPopupDragHandle; diff --git a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupFooter.tsx b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupFooter.tsx deleted file mode 100644 index bb241136a42a8..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupFooter.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Box } from '@rocket.chat/fuselage'; -import type { ReactNode } from 'react'; - -const VoipPopupFooter = ({ children }: { children: ReactNode }) => ( - - {children} - -); - -export default VoipPopupFooter; diff --git a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupHeader.spec.tsx b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupHeader.spec.tsx deleted file mode 100644 index 021037171e0bb..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupHeader.spec.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import VoipPopupHeader from './VoipPopupHeader'; - -it('should render title', () => { - render(voice call header title, { wrapper: mockAppRoot().build() }); - - expect(screen.getByText('voice call header title')).toBeInTheDocument(); -}); - -it('should not render close button when onClose is not provided', () => { - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); -}); - -it('should render close button when onClose is provided', () => { - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); -}); - -it('should call onClose when close button is clicked', async () => { - const closeFn = jest.fn(); - render(, { wrapper: mockAppRoot().build() }); - - await userEvent.click(screen.getByRole('button', { name: 'Close' })); - expect(closeFn).toHaveBeenCalled(); -}); - -it('should render settings button by default', () => { - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByRole('button', { name: /Device_settings/ })).toBeInTheDocument(); -}); - -it('should not render settings button when hideSettings is true', () => { - render(text, { wrapper: mockAppRoot().build() }); - - expect(screen.queryByRole('button', { name: /Device_settings/ })).not.toBeInTheDocument(); -}); diff --git a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupHeader.tsx b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupHeader.tsx deleted file mode 100644 index dc7c2b1e3dde4..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupHeader.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Box, IconButton } from '@rocket.chat/fuselage'; -import type { ReactElement, ReactNode } from 'react'; -import { useTranslation } from 'react-i18next'; - -import VoipSettingsButton from '../../VoipSettingsButton'; - -type VoipPopupHeaderProps = { - children?: ReactNode; - hideSettings?: boolean; - onClose?: () => void; -}; - -const VoipPopupHeader = ({ children, hideSettings, onClose }: VoipPopupHeaderProps): ReactElement => { - const { t } = useTranslation(); - - return ( - - {children && ( - - {children} - - )} - - {!hideSettings && ( - - - - )} - - {onClose && } - - ); -}; -export default VoipPopupHeader; diff --git a/packages/ui-voip/src/components/VoipPopup/index.ts b/packages/ui-voip/src/components/VoipPopup/index.ts deleted file mode 100644 index 753fa81fc6272..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './VoipPopup'; diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.spec.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.spec.tsx deleted file mode 100644 index b2e52863bf6b4..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.spec.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import VoipDialerView from './VoipDialerView'; - -const makeCall = jest.fn(); -const closeDialer = jest.fn(); - -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: appRoot.build() }); - - expect(screen.getByText('New_Call')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Device_settings/ })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Call/i })).toBeDisabled(); -}); - -it('should only enable call button if input has value (keyboard)', async () => { - render(, { wrapper: appRoot.build() }); - - expect(screen.getByRole('button', { name: /Call/i })).toBeDisabled(); - await userEvent.type(screen.getByLabelText('Phone_number'), '123'); - expect(screen.getByRole('button', { name: /Call/i })).toBeEnabled(); -}); - -it('should only enable call button if input has value (mouse)', async () => { - render(, { wrapper: appRoot.build() }); - - expect(screen.getByRole('button', { name: /Call/i })).toBeDisabled(); - - await userEvent.click(screen.getByTestId(`dial-pad-button-1`)); - await userEvent.click(screen.getByTestId(`dial-pad-button-2`)); - await userEvent.click(screen.getByTestId(`dial-pad-button-3`)); - expect(screen.getByRole('button', { name: /Call/i })).toBeEnabled(); -}); - -it('should call methods makeCall and closeDialer when call button is clicked', async () => { - render(, { wrapper: appRoot.build() }); - - await userEvent.type(screen.getByLabelText('Phone_number'), '123'); - await userEvent.click(screen.getByTestId(`dial-pad-button-1`)); - await userEvent.click(screen.getByRole('button', { name: /Call/i })); - expect(makeCall).toHaveBeenCalledWith('1231'); - expect(closeDialer).toHaveBeenCalled(); -}); diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx deleted file mode 100644 index bb890ae5214b4..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Button, ButtonGroup } from '@rocket.chat/fuselage'; -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'; -import Content from '../components/VoipPopupContent'; -import Footer from '../components/VoipPopupFooter'; -import Header from '../components/VoipPopupHeader'; - -type VoipDialerViewProps = { - position?: PositionOffsets; - dragHandleRef?: Ref; -}; - -const VoipDialerView = forwardRef(function VoipDialerView({ position, ...props }, ref) { - const { t } = useTranslation(); - const { makeCall, closeDialer } = useVoipAPI(); - const [number, setNumber] = useState(''); - - const handleCall = useDevicePermissionPrompt({ - actionType: 'outgoing', - onAccept: () => { - makeCall(number); - closeDialer(); - }, - }); - - return ( - -
- {t('New_Call')} -
- - - setNumber(value)} /> - - -
- - - - -
-
- ); -}); - -export default VoipDialerView; diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.spec.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.spec.tsx deleted file mode 100644 index 25bcabc52a30d..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.spec.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import VoipErrorView from './VoipErrorView'; -import { createMockFreeSwitchExtensionDetails, createMockVoipErrorSession } from '../../../tests/mocks'; - -const appRoot = mockAppRoot().withEndpoint('GET', '/v1/voip-freeswitch.extension.getDetails', () => createMockFreeSwitchExtensionDetails()); - -it('should properly render error view', async () => { - const errorSession = createMockVoipErrorSession({ error: { status: -1, reason: '' } }); - render(, { wrapper: appRoot.build() }); - - expect(screen.queryByLabelText('Device_settings')).not.toBeInTheDocument(); - expect(await screen.findByText('Administrator')).toBeInTheDocument(); -}); - -it('should only enable error actions', () => { - const errorSession = createMockVoipErrorSession({ error: { status: -1, reason: '' } }); - render(, { wrapper: appRoot.build() }); - - expect(within(screen.getByTestId('vc-popup-footer')).queryAllByRole('button')).toHaveLength(5); - expect(screen.getByRole('button', { name: 'Turn_off_microphone' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Hold' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Open_Dialpad' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Transfer_call' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'End_call' })).toBeEnabled(); -}); - -it('should properly interact with the voice call session', async () => { - const errorSession = createMockVoipErrorSession({ error: { status: -1, reason: '' } }); - render(, { wrapper: appRoot.build() }); - - await userEvent.click(screen.getByRole('button', { name: 'End_call' })); - expect(errorSession.end).toHaveBeenCalled(); -}); - -it('should properly render unknown error calls', async () => { - const session = createMockVoipErrorSession({ error: { status: -1, reason: '' } }); - render(, { wrapper: appRoot.build() }); - - expect(screen.getByText('Unable_to_complete_call__code')).toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', { name: 'End_call' })); - expect(session.end).toHaveBeenCalled(); -}); - -it('should properly render error for unavailable calls', async () => { - const session = createMockVoipErrorSession({ error: { status: 480, reason: '' } }); - render(, { wrapper: appRoot.build() }); - - expect(screen.getByText('Temporarily_unavailable')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'End_call' })).toBeEnabled(); - await userEvent.click(screen.getByRole('button', { name: 'End_call' })); - expect(session.end).toHaveBeenCalled(); -}); - -it('should properly render error for busy calls', async () => { - const session = createMockVoipErrorSession({ error: { status: 486, reason: '' } }); - render(, { wrapper: appRoot.build() }); - - expect(screen.getByText('Caller_is_busy')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'End_call' })).toBeEnabled(); - await userEvent.click(screen.getByRole('button', { name: 'End_call' })); - expect(session.end).toHaveBeenCalled(); -}); - -it('should properly render error for terminated calls', async () => { - const session = createMockVoipErrorSession({ error: { status: 487, reason: '' } }); - render(, { wrapper: appRoot.build() }); - - expect(screen.getByText('Call_terminated')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'End_call' })).toBeEnabled(); - await userEvent.click(screen.getByRole('button', { name: 'End_call' })); - expect(session.end).toHaveBeenCalled(); -}); diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.tsx deleted file mode 100644 index 33a3e81e027fc..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Box, Icon } from '@rocket.chat/fuselage'; -import { useMemo, forwardRef, Ref } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { VoipActions as Actions, VoipContactId as CallContactId } from '../..'; -import type { VoipErrorSession } from '../../../definitions'; -import { useVoipContactId } from '../../../hooks/useVoipContactId'; -import Container from '../components/VoipPopupContainer'; -import type { PositionOffsets } from '../components/VoipPopupContainer'; -import Content from '../components/VoipPopupContent'; -import Footer from '../components/VoipPopupFooter'; -import Header from '../components/VoipPopupHeader'; - -type VoipErrorViewProps = { - session: VoipErrorSession; - position?: PositionOffsets; - dragHandleRef?: Ref; -}; - -const VoipErrorView = forwardRef(function VoipErrorView({ session, position, ...props }, ref) { - const { t } = useTranslation(); - const contactData = useVoipContactId({ session }); - - const { status } = session.error; - - const title = useMemo(() => { - switch (status) { - case 488: - return t('Unable_to_negotiate_call_params'); - case 487: - return t('Call_terminated'); - case 486: - return t('Caller_is_busy'); - case 480: - return t('Temporarily_unavailable'); - default: - return t('Unable_to_complete_call__code', { statusCode: status }); - } - }, [status, t]); - - return ( - -
- - {title} - -
- - - - - -
- -
-
- ); -}); - -export default VoipErrorView; diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipIncomingView.spec.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipIncomingView.spec.tsx deleted file mode 100644 index 43f9a3ac82d61..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipIncomingView.spec.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { render, screen, within } from '@testing-library/react'; -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()) - .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(, { wrapper: appRoot.build() }); - - expect(screen.getByText('Incoming_call...')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Device_settings/ })).toBeInTheDocument(); - expect(await screen.findByText('Administrator')).toBeInTheDocument(); -}); - -it('should only enable incoming actions', () => { - render(, { wrapper: appRoot.build() }); - - expect(within(screen.getByTestId('vc-popup-footer')).queryAllByRole('button')).toHaveLength(5); - expect(screen.getByRole('button', { name: 'Decline' })).toBeEnabled(); - expect(screen.getByRole('button', { name: 'Turn_off_microphone' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Open_Dialpad' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Transfer_call' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Accept' })).toBeEnabled(); -}); - -it('should properly interact with the voice call session', async () => { - render(, { wrapper: appRoot.build() }); - - await userEvent.click(screen.getByRole('button', { name: 'Decline' })); - await userEvent.click(screen.getByRole('button', { name: 'Accept' })); - - expect(incomingSession.end).toHaveBeenCalled(); - expect(incomingSession.accept).toHaveBeenCalled(); -}); diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipIncomingView.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipIncomingView.tsx deleted file mode 100644 index e4c9340887f8e..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipIncomingView.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { forwardRef, Ref } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { VoipActions as Actions, VoipContactId as CallContactId } from '../..'; -import type { VoipIncomingSession } from '../../../definitions'; -import { useVoipContactId } from '../../../hooks/useVoipContactId'; -import Container from '../components/VoipPopupContainer'; -import type { PositionOffsets } from '../components/VoipPopupContainer'; -import Content from '../components/VoipPopupContent'; -import Footer from '../components/VoipPopupFooter'; -import Header from '../components/VoipPopupHeader'; - -type VoipIncomingViewProps = { - session: VoipIncomingSession; - position?: PositionOffsets; - dragHandleRef?: Ref; -}; - -const VoipIncomingView = forwardRef(function VoipIncomingView({ session, position, ...props }, ref) { - const { t } = useTranslation(); - const contactData = useVoipContactId({ session }); - - return ( - -
{`${session.transferedBy ? t('Incoming_call_transfer') : t('Incoming_call')}...`}
- - - - - -
- -
-
- ); -}); - -export default VoipIncomingView; diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipOngoingView.spec.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipOngoingView.spec.tsx deleted file mode 100644 index 9bc5b512c7311..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipOngoingView.spec.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import VoipOngoingView from './VoipOngoingView'; -import { createMockFreeSwitchExtensionDetails, createMockVoipOngoingSession } from '../../../tests/mocks'; - -const wrapper = mockAppRoot().withEndpoint('GET', '/v1/voip-freeswitch.extension.getDetails', () => createMockFreeSwitchExtensionDetails()); - -const ongoingSession = createMockVoipOngoingSession(); - -const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - -beforeEach(() => { - jest.useFakeTimers(); -}); - -afterEach(() => { - jest.clearAllTimers(); -}); - -it('should properly render ongoing view', async () => { - render(, { wrapper: wrapper.build() }); - - expect(screen.getByText('00:00')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Device_settings/ })).toBeInTheDocument(); - expect(await screen.findByText('Administrator')).toBeInTheDocument(); - expect(screen.queryByText('On_Hold')).not.toBeInTheDocument(); - expect(screen.queryByText('Muted')).not.toBeInTheDocument(); -}); - -it('should display on hold and muted', () => { - ongoingSession.isMuted = true; - ongoingSession.isHeld = true; - - render(, { wrapper: wrapper.build() }); - - expect(screen.getByText('On_Hold')).toBeInTheDocument(); - expect(screen.getByText('Muted')).toBeInTheDocument(); - - ongoingSession.isMuted = false; - ongoingSession.isHeld = false; -}); - -it('should only enable ongoing call actions', () => { - render(, { wrapper: wrapper.build() }); - - expect(within(screen.getByTestId('vc-popup-footer')).queryAllByRole('button')).toHaveLength(5); - expect(screen.getByRole('button', { name: 'Turn_off_microphone' })).toBeEnabled(); - expect(screen.getByRole('button', { name: 'Hold' })).toBeEnabled(); - expect(screen.getByRole('button', { name: 'Open_Dialpad' })).toBeEnabled(); - expect(screen.getByRole('button', { name: 'Transfer_call' })).toBeEnabled(); - expect(screen.getByRole('button', { name: 'End_call' })).toBeEnabled(); -}); - -it('should properly interact with the voice call session', async () => { - render(, { wrapper: wrapper.build() }); - - await user.click(screen.getByRole('button', { name: 'Turn_off_microphone' })); - expect(ongoingSession.mute).toHaveBeenCalled(); - - await user.click(screen.getByRole('button', { name: 'Hold' })); - expect(ongoingSession.hold).toHaveBeenCalled(); - - await user.click(screen.getByRole('button', { name: 'Open_Dialpad' })); - await user.click(screen.getByTestId('dial-pad-button-1')); - expect(screen.getByRole('textbox', { name: 'Phone_number' })).toHaveValue('1'); - expect(ongoingSession.dtmf).toHaveBeenCalledWith('1'); - - expect(screen.getByRole('button', { name: 'End_call' })).toBeEnabled(); - await user.click(screen.getByRole('button', { name: 'End_call' })); - expect(ongoingSession.end).toHaveBeenCalled(); -}); diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipOngoingView.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipOngoingView.tsx deleted file mode 100644 index 86142668851d7..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipOngoingView.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useState, forwardRef, Ref } from 'react'; - -import { - VoipActions as Actions, - VoipContactId as CallContactId, - VoipStatus as Status, - VoipDialPad as DialPad, - VoipTimer as Timer, -} from '../..'; -import type { VoipOngoingSession } from '../../../definitions'; -import { useVoipContactId } from '../../../hooks/useVoipContactId'; -import { useVoipTransferModal } from '../../../hooks/useVoipTransferModal'; -import Container from '../components/VoipPopupContainer'; -import type { PositionOffsets } from '../components/VoipPopupContainer'; -import Content from '../components/VoipPopupContent'; -import Footer from '../components/VoipPopupFooter'; -import Header from '../components/VoipPopupHeader'; - -type VoipOngoingViewProps = { - session: VoipOngoingSession; - position?: PositionOffsets; - dragHandleRef?: Ref; -}; - -const VoipOngoingView = forwardRef(function VoipOngoingView({ session, position, ...props }, ref) { - const { startTransfer } = useVoipTransferModal({ session }); - const contactData = useVoipContactId({ session, transferEnabled: false }); - - const [isDialPadOpen, setDialerOpen] = useState(false); - const [dtmfValue, setDTMF] = useState(''); - - const handleDTMF = (value: string, digit: string) => { - setDTMF(value); - if (digit) { - session.dtmf(digit); - } - }; - - return ( - -
- -
- - - - - - - {isDialPadOpen && } - - -
- setDialerOpen(!isDialPadOpen)} - /> -
-
- ); -}); - -export default VoipOngoingView; diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipOutgoingView.spec.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipOutgoingView.spec.tsx deleted file mode 100644 index d5b8499be3666..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipOutgoingView.spec.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import VoipOutgoingView from './VoipOutgoingView'; -import { createMockFreeSwitchExtensionDetails, createMockVoipOutgoingSession } from '../../../tests/mocks'; - -const wrapper = mockAppRoot().withEndpoint('GET', '/v1/voip-freeswitch.extension.getDetails', () => createMockFreeSwitchExtensionDetails()); - -const outgoingSession = createMockVoipOutgoingSession(); - -it('should properly render outgoing view', async () => { - render(, { wrapper: wrapper.build() }); - - expect(screen.getByText('Calling...')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Device_settings/ })).toBeInTheDocument(); - expect(await screen.findByText('Administrator')).toBeInTheDocument(); -}); - -it('should only enable outgoing actions', () => { - render(, { wrapper: wrapper.build() }); - - expect(within(screen.getByTestId('vc-popup-footer')).queryAllByRole('button')).toHaveLength(5); - expect(screen.getByRole('button', { name: 'Turn_off_microphone' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Hold' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Open_Dialpad' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Transfer_call' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'End_call' })).toBeEnabled(); -}); - -it('should properly interact with the voice call session', async () => { - render(, { wrapper: wrapper.build() }); - - await userEvent.click(screen.getByRole('button', { name: 'End_call' })); - expect(outgoingSession.end).toHaveBeenCalled(); -}); diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipOutgoingView.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipOutgoingView.tsx deleted file mode 100644 index aa7d25a66f8e5..0000000000000 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipOutgoingView.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { forwardRef, Ref } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { VoipActions as Actions, VoipContactId as CallContactId } from '../..'; -import type { VoipOutgoingSession } from '../../../definitions'; -import { useVoipContactId } from '../../../hooks/useVoipContactId'; -import Container from '../components/VoipPopupContainer'; -import type { PositionOffsets } from '../components/VoipPopupContainer'; -import Content from '../components/VoipPopupContent'; -import Footer from '../components/VoipPopupFooter'; -import Header from '../components/VoipPopupHeader'; - -type VoipOutgoingViewProps = { - session: VoipOutgoingSession; - position?: PositionOffsets; - dragHandleRef?: Ref; -}; - -const VoipOutgoingView = forwardRef(function VoipOutgoingView({ session, position, ...props }, ref) { - const { t } = useTranslation(); - const contactData = useVoipContactId({ session }); - - return ( - -
{`${t('Calling')}...`}
- - - - - -
- -
-
- ); -}); - -export default VoipOutgoingView; diff --git a/packages/ui-voip/src/components/VoipPopupDraggable/VoipPopupDraggable.stories.tsx b/packages/ui-voip/src/components/VoipPopupDraggable/VoipPopupDraggable.stories.tsx deleted file mode 100644 index 9d5691bcfd847..0000000000000 --- a/packages/ui-voip/src/components/VoipPopupDraggable/VoipPopupDraggable.stories.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Box } from '@rocket.chat/fuselage'; -import type { Meta, StoryObj } from '@storybook/react'; - -import VoipPopupDraggable from './VoipPopupDraggable'; -import { createMockVoipProviders } from '../../tests/mocks'; - -const [MockedProviders] = createMockVoipProviders(); - -export default { - title: 'Voip/VoipPopupDraggable', - component: VoipPopupDraggable, - parameters: { - layout: 'fullscreen', - }, - decorators: [ - (Story) => ( - - - - - - ), - ], -} satisfies Meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - initialPosition: { top: 20, right: 20 }, - }, -}; - -export const TopLeft: Story = { - args: { - initialPosition: { top: 20, left: 20 }, - }, -}; - -export const BottomRight: Story = { - args: { - initialPosition: { bottom: 20, right: 20 }, - }, -}; - -export const BottomLeft: Story = { - args: { - initialPosition: { bottom: 20, left: 20 }, - }, -}; diff --git a/packages/ui-voip/src/components/VoipPopupDraggable/VoipPopupDraggable.tsx b/packages/ui-voip/src/components/VoipPopupDraggable/VoipPopupDraggable.tsx deleted file mode 100644 index 123fd9e2af795..0000000000000 --- a/packages/ui-voip/src/components/VoipPopupDraggable/VoipPopupDraggable.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useLayoutEffect } from 'react'; - -import { useDraggable } from './DraggableCore'; -import VoipPopup from '../VoipPopup'; -import type { PositionOffsets } from '../VoipPopup/components/VoipPopupContainer'; - -type VoipPopupDraggableProps = { - initialPosition?: PositionOffsets; -}; - -const VoipPopupDraggable = ({ initialPosition = { top: 20, right: 20 } }: VoipPopupDraggableProps) => { - const [draggableRef, containerRef, handleElementRef] = useDraggable(); - - useLayoutEffect(() => { - containerRef(document.querySelector('body')); - }, [containerRef]); - - return ; -}; - -export default VoipPopupDraggable; diff --git a/packages/ui-voip/src/components/VoipPopupDraggable/index.ts b/packages/ui-voip/src/components/VoipPopupDraggable/index.ts deleted file mode 100644 index 5db128ca85b57..0000000000000 --- a/packages/ui-voip/src/components/VoipPopupDraggable/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './VoipPopupDraggable'; diff --git a/packages/ui-voip/src/components/VoipPopupDraggable/useDraggable.stories.tsx b/packages/ui-voip/src/components/VoipPopupDraggable/useDraggable.stories.tsx index 8a10245222d26..178ec23606c89 100644 --- a/packages/ui-voip/src/components/VoipPopupDraggable/useDraggable.stories.tsx +++ b/packages/ui-voip/src/components/VoipPopupDraggable/useDraggable.stories.tsx @@ -1,10 +1,10 @@ import { Box, Button } from '@rocket.chat/fuselage'; +import { AnchorPortal } from '@rocket.chat/ui-client'; import type { Meta, StoryObj } from '@storybook/react'; import { within, fireEvent, waitFor, expect, userEvent } from '@storybook/test'; import { useEffect, useLayoutEffect, useState, type Ref } from 'react'; import { useDraggable, DEFAULT_BOUNDING_ELEMENT_OPTIONS } from './DraggableCore'; -import VoipPopupPortal from '../VoipPopupPortal'; class BoundingBoxResizeEvent extends Event { constructor( @@ -469,9 +469,9 @@ const MockedPage = () => { - + - + ); }; diff --git a/packages/ui-voip/src/components/VoipPopupPortal/VoipPopupPortal.tsx b/packages/ui-voip/src/components/VoipPopupPortal/VoipPopupPortal.tsx deleted file mode 100644 index d0f3c015e51bf..0000000000000 --- a/packages/ui-voip/src/components/VoipPopupPortal/VoipPopupPortal.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { AnchorPortal } from '@rocket.chat/ui-client'; -import type { ReactElement, ReactNode } from 'react'; -import { memo } from 'react'; - -const voipAnchorId = 'voip-root'; - -type VoipPopupPortalProps = { - children?: ReactNode; -}; - -const VoipPopupPortal = ({ children }: VoipPopupPortalProps): ReactElement => { - return {children}; -}; - -export default memo(VoipPopupPortal); diff --git a/packages/ui-voip/src/components/VoipPopupPortal/index.ts b/packages/ui-voip/src/components/VoipPopupPortal/index.ts deleted file mode 100644 index 47db7440804f1..0000000000000 --- a/packages/ui-voip/src/components/VoipPopupPortal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './VoipPopupPortal'; diff --git a/packages/ui-voip/src/components/VoipSettingsButton/VoipSettingsButton.tsx b/packages/ui-voip/src/components/VoipSettingsButton/VoipSettingsButton.tsx deleted file mode 100644 index 1e9b5d397ae96..0000000000000 --- a/packages/ui-voip/src/components/VoipSettingsButton/VoipSettingsButton.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* 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, useCallback, useState } from 'react'; - -import { useVoipDeviceSettings } from './hooks/useVoipDeviceSettings'; -import { useDevicePermissionPrompt } from '../../hooks/useDevicePermissionPrompt'; - -const CustomizeButton = forwardRef(function CustomizeButton( - { mini, ...props }: ComponentProps, - ref: Ref, -) { - const size = mini ? 24 : 32; - return ; -}); - -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 ( - - ); -}; - -export default VoipSettingsButton; diff --git a/packages/ui-voip/src/components/VoipSettingsButton/hooks/useVoipDeviceSettings.spec.tsx b/packages/ui-voip/src/components/VoipSettingsButton/hooks/useVoipDeviceSettings.spec.tsx deleted file mode 100644 index 2f42a48a3c8bd..0000000000000 --- a/packages/ui-voip/src/components/VoipSettingsButton/hooks/useVoipDeviceSettings.spec.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { renderHook } from '@testing-library/react'; - -import { useVoipDeviceSettings } from './useVoipDeviceSettings'; - -it('should be disabled when there are no devices', () => { - const { result } = renderHook(() => useVoipDeviceSettings(), { - wrapper: mockAppRoot().build(), - }); - - expect(result.current.title).toBe('Device_settings_not_supported_by_browser'); - expect(result.current.disabled).toBeTruthy(); -}); - -it('should be enabled when there are devices', () => { - const { result } = renderHook(() => useVoipDeviceSettings(), { - wrapper: mockAppRoot() - .withAudioInputDevices([{ type: '', id: '', label: '' }]) - .withAudioOutputDevices([{ type: '', id: '', label: '' }]) - .build(), - }); - - expect(result.current.title).toBe('Device_settings'); - expect(result.current.disabled).toBeFalsy(); -}); diff --git a/packages/ui-voip/src/components/VoipSettingsButton/hooks/useVoipDeviceSettings.tsx b/packages/ui-voip/src/components/VoipSettingsButton/hooks/useVoipDeviceSettings.tsx deleted file mode 100644 index e371661978f88..0000000000000 --- a/packages/ui-voip/src/components/VoipSettingsButton/hooks/useVoipDeviceSettings.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { Box, RadioButton } from '@rocket.chat/fuselage'; -import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; -import { useAvailableDevices, useSelectedDevices, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; -import { useMutation } from '@tanstack/react-query'; -import type { MouseEvent } from 'react'; -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: ( - - {label} - - ), - addon: undefined} checked={true} disabled />, - id: `default-${type}`, -}); - -export const useVoipDeviceSettings = () => { - const { t } = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - - const { changeAudioInputDevice, changeAudioOutputDevice } = useVoipAPI(); - const availableDevices = useAvailableDevices(); - const selectedAudioDevices = useSelectedDevices(); - - const changeInputDevice = useMutation({ - mutationFn: changeAudioInputDevice, - onSuccess: () => dispatchToastMessage({ type: 'success', message: t('Devices_Set') }), - onError: (error) => dispatchToastMessage({ type: 'error', message: error }), - }); - - const changeOutputDevice = useMutation({ - mutationFn: changeAudioOutputDevice, - onSuccess: () => dispatchToastMessage({ type: 'success', message: t('Devices_Set') }), - onError: (error) => dispatchToastMessage({ type: 'error', message: error }), - }); - - const availableInputDevice = - availableDevices?.audioInput?.map((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: ( - - {device.label} - - ), - addon: ( - changeInputDevice.mutate(device)} checked={device.id === selectedAudioDevices?.audioInput?.id} /> - ), - }; - }) || []; - - const availableOutputDevice = - availableDevices?.audioOutput?.map((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: ( - - {device.label} - - ), - addon: ( - changeOutputDevice.mutate(device)} checked={device.id === selectedAudioDevices?.audioOutput?.id} /> - ), - onClick(e?: MouseEvent) { - 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; - - return { - disabled, - title: disabled ? t('Device_settings_not_supported_by_browser') : t('Device_settings'), - sections: [micSection, speakerSection], - }; -}; diff --git a/packages/ui-voip/src/components/VoipSettingsButton/index.ts b/packages/ui-voip/src/components/VoipSettingsButton/index.ts deleted file mode 100644 index f99032fedee59..0000000000000 --- a/packages/ui-voip/src/components/VoipSettingsButton/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './VoipSettingsButton'; -export * from './hooks/useVoipDeviceSettings'; diff --git a/packages/ui-voip/src/components/VoipStatus/VoipStatus.spec.tsx b/packages/ui-voip/src/components/VoipStatus/VoipStatus.spec.tsx deleted file mode 100644 index ffeb0b48cd3e1..0000000000000 --- a/packages/ui-voip/src/components/VoipStatus/VoipStatus.spec.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { render, screen } from '@testing-library/react'; - -import VoipStatus from './VoipStatus'; - -it('should not render any status', () => { - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.queryByText('On_Hold')).not.toBeInTheDocument(); - expect(screen.queryByText('Muted')).not.toBeInTheDocument(); -}); - -it('should display on hold status', () => { - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByText('On_Hold')).toBeInTheDocument(); - expect(screen.queryByText('Muted')).not.toBeInTheDocument(); -}); - -it('should display muted status', () => { - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.queryByText('On_Hold')).not.toBeInTheDocument(); - expect(screen.getByText('Muted')).toBeInTheDocument(); -}); - -it('should display all statuses', () => { - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByText('On_Hold')).toBeInTheDocument(); - expect(screen.getByText('Muted')).toBeInTheDocument(); -}); diff --git a/packages/ui-voip/src/components/VoipStatus/VoipStatus.tsx b/packages/ui-voip/src/components/VoipStatus/VoipStatus.tsx deleted file mode 100644 index dc466bdf0e6be..0000000000000 --- a/packages/ui-voip/src/components/VoipStatus/VoipStatus.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Box } from '@rocket.chat/fuselage'; -import { useTranslation } from 'react-i18next'; - -const VoipStatus = ({ isHeld = false, isMuted = false }: { isHeld: boolean; isMuted: boolean }) => { - const { t } = useTranslation(); - - if (!isHeld && !isMuted) { - return null; - } - - return ( - - {isHeld && ( - - {t('On_Hold')} - - )} - - {isMuted && ( - - {t('Muted')} - - )} - - ); -}; - -export default VoipStatus; diff --git a/packages/ui-voip/src/components/VoipStatus/index.ts b/packages/ui-voip/src/components/VoipStatus/index.ts deleted file mode 100644 index 91d89ce79241b..0000000000000 --- a/packages/ui-voip/src/components/VoipStatus/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './VoipStatus'; -export * from './VoipStatus'; diff --git a/packages/ui-voip/src/components/VoipTimer/VoipTimer.spec.tsx b/packages/ui-voip/src/components/VoipTimer/VoipTimer.spec.tsx deleted file mode 100644 index cbb97c95232fc..0000000000000 --- a/packages/ui-voip/src/components/VoipTimer/VoipTimer.spec.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { act, render, screen } from '@testing-library/react'; - -import VoipTimer from './VoipTimer'; - -beforeEach(() => { - jest.useFakeTimers(); -}); - -afterEach(() => { - jest.clearAllTimers(); -}); - -it('should display the initial time correctly', () => { - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByText('00:00')).toBeInTheDocument(); -}); - -it('should update the time after a few seconds', () => { - render(, { wrapper: mockAppRoot().build() }); - - act(() => { - jest.advanceTimersByTime(5000); - }); - - expect(screen.getByText('00:05')).toBeInTheDocument(); -}); - -it('should start with a minute on the timer', () => { - const startTime = new Date(); - startTime.setMinutes(startTime.getMinutes() - 1); - render(, { wrapper: mockAppRoot().build() }); - - expect(screen.getByText('01:00')).toBeInTheDocument(); -}); diff --git a/packages/ui-voip/src/components/VoipTimer/VoipTimer.tsx b/packages/ui-voip/src/components/VoipTimer/VoipTimer.tsx deleted file mode 100644 index e90eeaee510f3..0000000000000 --- a/packages/ui-voip/src/components/VoipTimer/VoipTimer.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; - -import { setPreciseInterval } from '../../utils/setPreciseInterval'; - -type VoipTimerProps = { startAt?: Date }; - -const VoipTimer = ({ startAt = new Date() }: VoipTimerProps) => { - const [start] = useState(startAt.getTime()); - const [ellapsedTime, setEllapsedTime] = useState(0); - - useEffect(() => { - return setPreciseInterval(() => { - setEllapsedTime(Date.now() - start); - }, 1000); - }); - - const [hours, minutes, seconds] = useMemo(() => { - const totalSeconds = Math.floor(ellapsedTime / 1000); - - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = Math.floor(totalSeconds % 60); - - return [hours.toString().padStart(2, '0'), minutes.toString().padStart(2, '0'), seconds.toString().padStart(2, '0')]; - }, [ellapsedTime]); - - return ( - - ); -}; - -export default VoipTimer; diff --git a/packages/ui-voip/src/components/VoipTimer/index.ts b/packages/ui-voip/src/components/VoipTimer/index.ts deleted file mode 100644 index 9b7e432819709..0000000000000 --- a/packages/ui-voip/src/components/VoipTimer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './VoipTimer'; diff --git a/packages/ui-voip/src/components/VoipTransferModal/VoipTransferModal.spec.tsx b/packages/ui-voip/src/components/VoipTransferModal/VoipTransferModal.spec.tsx deleted file mode 100644 index c063b8c47605f..0000000000000 --- a/packages/ui-voip/src/components/VoipTransferModal/VoipTransferModal.spec.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import VoipTransferModal from './VoipTransferModal'; - -it('should be able to select transfer target', async () => { - const confirmFn = jest.fn(); - render( undefined} />, { - wrapper: mockAppRoot() - .withJohnDoe() - .withEndpoint('GET', '/v1/users.autocomplete', () => ({ - items: [ - { - _id: 'janedoe', - score: 2, - name: 'Jane Doe', - username: 'jane.doe', - nickname: null, - status: 'offline', - statusText: '', - avatarETag: null, - } as any, - ], - success: true, - })) - .withEndpoint('GET', '/v1/users.info', () => ({ - user: { - _id: 'pC85DQzdv8zmzXNT8', - createdAt: '2023-04-12T18:02:08.145Z', - username: 'jane.doe', - emails: [ - { - address: 'jane.doe@email.com', - verified: true, - }, - ], - type: 'user', - active: true, - roles: ['user', 'livechat-agent', 'livechat-monitor'], - name: 'Jane Doe', - requirePasswordChange: false, - statusText: '', - lastLogin: '2024-08-19T18:21:58.442Z', - statusConnection: 'offline', - utcOffset: -3, - freeSwitchExtension: '1011', - canViewAllInfo: true, - _updatedAt: '', - }, - success: true, - })) - .build(), - }); - const hangUpAnTransferButton = screen.getByRole('button', { name: 'Hang_up_and_transfer_call' }); - - expect(hangUpAnTransferButton).toBeDisabled(); - - await userEvent.type(screen.getByRole('textbox', { name: 'Transfer_to' }), 'Jane Doe'); - - const userOption = await screen.findByRole('option', { name: 'Jane Doe' }); - - await userEvent.click(userOption); - - expect(hangUpAnTransferButton).toBeEnabled(); - await userEvent.click(hangUpAnTransferButton); - - expect(confirmFn).toHaveBeenCalledWith({ extension: '1011', name: 'Jane Doe' }); -}); - -it('should call onCancel when Cancel is clicked', async () => { - const confirmFn = jest.fn(); - const cancelFn = jest.fn(); - render(, { - wrapper: mockAppRoot().build(), - }); - - await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); - - expect(cancelFn).toHaveBeenCalled(); -}); - -it('should call onCancel when X is clicked', async () => { - const confirmFn = jest.fn(); - const cancelFn = jest.fn(); - render(, { - wrapper: mockAppRoot().build(), - }); - - await userEvent.click(screen.getByRole('button', { name: 'Close' })); - - expect(cancelFn).toHaveBeenCalled(); -}); diff --git a/packages/ui-voip/src/components/VoipTransferModal/VoipTransferModal.tsx b/packages/ui-voip/src/components/VoipTransferModal/VoipTransferModal.tsx deleted file mode 100644 index 5b6bca985c259..0000000000000 --- a/packages/ui-voip/src/components/VoipTransferModal/VoipTransferModal.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { - Button, - Field, - FieldHint, - FieldLabel, - FieldRow, - Modal, - ModalClose, - ModalContent, - ModalFooter, - ModalFooterControllers, - ModalHeader, - ModalIcon, - ModalTitle, -} from '@rocket.chat/fuselage'; -import { UserAutoComplete } from '@rocket.chat/ui-client'; -import { useEndpoint, useUser } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; -import { useId, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -type VoipTransferModalProps = { - extension: string; - isLoading?: boolean; - onCancel(): void; - onConfirm(params: { extension: string; name: string | undefined }): void; -}; - -const VoipTransferModal = ({ extension, isLoading = false, onCancel, onConfirm }: VoipTransferModalProps) => { - const { t } = useTranslation(); - const [username, setTransferTo] = useState(''); - const user = useUser(); - const transferToId = useId(); - const modalId = useId(); - - const getUserInfo = useEndpoint('GET', '/v1/users.info'); - const { data: targetUser, isLoading: isTargetInfoLoading } = useQuery({ - queryKey: ['/v1/users.info', username], - queryFn: () => getUserInfo({ username }), - enabled: Boolean(username), - select: (data) => data?.user || {}, - }); - - const handleConfirm = () => { - if (!targetUser?.freeSwitchExtension) { - return; - } - - onConfirm({ extension: targetUser.freeSwitchExtension, name: targetUser.name }); - }; - - return ( - - - - {t('Transfer_call')} - - - - - {t('Transfer_to')} - - setTransferTo(target as string)} - multiple={false} - conditions={{ - freeSwitchExtension: { $exists: true, $ne: extension }, - username: { $ne: user?.username }, - }} - /> - - {t('Select_someone_to_transfer_the_call_to')} - - - - - - - - - - ); -}; - -export default VoipTransferModal; diff --git a/packages/ui-voip/src/components/VoipTransferModal/index.ts b/packages/ui-voip/src/components/VoipTransferModal/index.ts deleted file mode 100644 index a38c21026718e..0000000000000 --- a/packages/ui-voip/src/components/VoipTransferModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './VoipTransferModal'; diff --git a/packages/ui-voip/src/components/index.ts b/packages/ui-voip/src/components/index.ts deleted file mode 100644 index 52913b2aa6b22..0000000000000 --- a/packages/ui-voip/src/components/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { default as VoipDialPad } from './VoipDialPad'; -export { default as VoipTimer } from './VoipTimer'; -export { default as VoipStatus } from './VoipStatus'; -export { default as VoipContactId } from './VoipContactId'; -export { default as VoipActionButton } from './VoipActionButton'; -export { default as VoipActions } from './VoipActions'; -export { default as VoipSettingsButton } from './VoipSettingsButton'; -export { default as VoipPopupDraggable } from './VoipPopupDraggable'; diff --git a/packages/ui-voip/src/contexts/VoipContext.tsx b/packages/ui-voip/src/contexts/VoipContext.tsx deleted file mode 100644 index fd7700d54da54..0000000000000 --- a/packages/ui-voip/src/contexts/VoipContext.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { type Device } from '@rocket.chat/ui-contexts'; -import { createContext } from 'react'; - -import type VoIPClient from '../lib/VoipClient'; - -type VoipContextDisabled = { - isEnabled: false; - voipClient?: null; - error?: null; -}; - -export type VoipContextError = { - isEnabled: true; - error: Error; - voipClient: null; - changeAudioOutputDevice: (selectedAudioDevices: Device) => Promise; - changeAudioInputDevice: (selectedAudioDevices: Device) => Promise; -}; - -export type VoipContextEnabled = { - isEnabled: true; - voipClient: VoIPClient | null; - error?: null; - changeAudioOutputDevice: (selectedAudioDevices: Device) => Promise; - changeAudioInputDevice: (selectedAudioDevices: Device) => Promise; -}; - -export type VoipContextReady = { - isEnabled: true; - voipClient: VoIPClient; - error: null; - changeAudioOutputDevice: (selectedAudioDevices: Device) => Promise; - changeAudioInputDevice: (selectedAudioDevices: Device) => Promise; -}; - -export type VoipContextValue = VoipContextDisabled | VoipContextEnabled | VoipContextReady | VoipContextError; - -export const isVoipContextReady = (context: VoipContextValue): context is VoipContextReady => - context.isEnabled && context.voipClient !== null; - -export const VoipContext = createContext({ - isEnabled: false, - voipClient: null, -}); diff --git a/packages/ui-voip/src/definitions/VoipSession.ts b/packages/ui-voip/src/definitions/VoipSession.ts deleted file mode 100644 index e4bbd5f728ae7..0000000000000 --- a/packages/ui-voip/src/definitions/VoipSession.ts +++ /dev/null @@ -1,69 +0,0 @@ -export type ContactInfo = { - id: string; - name?: string; - host: string; -}; - -export type VoipGenericSession = { - type: 'INCOMING' | 'OUTGOING' | 'ONGOING' | 'ERROR'; - contact: ContactInfo | null; - transferedBy?: ContactInfo | null; - isMuted?: boolean; - isHeld?: boolean; - error?: { status?: number; reason: string }; - accept?(): Promise; - end?(): void; - mute?(mute?: boolean): void; - hold?(held?: boolean): void; - dtmf?(digit: string): void; -}; - -export type VoipOngoingSession = VoipGenericSession & { - type: 'ONGOING'; - contact: ContactInfo; - isMuted: boolean; - isHeld: boolean; - end(): void; - mute(muted?: boolean): void; - hold(held?: boolean): void; - dtmf(digit: string): void; -}; - -export type VoipIncomingSession = VoipGenericSession & { - type: 'INCOMING'; - contact: ContactInfo; - transferedBy: ContactInfo | null; - end(): void; - accept(): Promise; -}; - -export type VoipOutgoingSession = VoipGenericSession & { - type: 'OUTGOING'; - contact: ContactInfo; - end(): void; -}; - -export type VoipErrorSession = VoipGenericSession & { - type: 'ERROR'; - contact: ContactInfo; - error: { status?: number; reason: string }; - end(): void; -}; - -export type VoipSession = VoipIncomingSession | VoipOngoingSession | VoipOutgoingSession | VoipErrorSession; - -export const isVoipIncomingSession = (session: VoipSession | null | undefined): session is VoipIncomingSession => { - return session?.type === 'INCOMING'; -}; - -export const isVoipOngoingSession = (session: VoipSession | null | undefined): session is VoipOngoingSession => { - return session?.type === 'ONGOING'; -}; - -export const isVoipOutgoingSession = (session: VoipSession | null | undefined): session is VoipOutgoingSession => { - return session?.type === 'OUTGOING'; -}; - -export const isVoipErrorSession = (session: VoipSession | null | undefined): session is VoipErrorSession => { - return session?.type === 'ERROR'; -}; diff --git a/packages/ui-voip/src/definitions/index.ts b/packages/ui-voip/src/definitions/index.ts index b1d869534a0ef..de31860fccc37 100644 --- a/packages/ui-voip/src/definitions/index.ts +++ b/packages/ui-voip/src/definitions/index.ts @@ -1,2 +1 @@ -export * from './VoipSession'; export * from './IceServer'; diff --git a/packages/ui-voip/src/hooks/index.ts b/packages/ui-voip/src/hooks/index.ts index 340dfb2ead3ac..8b58b842e44f4 100644 --- a/packages/ui-voip/src/hooks/index.ts +++ b/packages/ui-voip/src/hooks/index.ts @@ -1,7 +1 @@ -export * from './useVoip'; -export * from './useVoipAPI'; -export * from './useVoipClient'; -export * from './useVoipDialer'; -export * from './useVoipSession'; -export * from './useVoipState'; export * from './useDevicePermissionPrompt'; diff --git a/packages/ui-voip/src/hooks/useVoip.tsx b/packages/ui-voip/src/hooks/useVoip.tsx deleted file mode 100644 index 1d54d4446117b..0000000000000 --- a/packages/ui-voip/src/hooks/useVoip.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useContext, useMemo } from 'react'; - -import { VoipContext } from '../contexts/VoipContext'; -import { useVoipAPI } from './useVoipAPI'; -import { useVoipSession } from './useVoipSession'; -import { useVoipState } from './useVoipState'; - -export const useVoip = () => { - const { error } = useContext(VoipContext); - const state = useVoipState(); - const session = useVoipSession(); - const api = useVoipAPI(); - - return useMemo( - () => ({ - ...state, - ...api, - session, - error, - }), - [state, api, session, error], - ); -}; diff --git a/packages/ui-voip/src/hooks/useVoipAPI.tsx b/packages/ui-voip/src/hooks/useVoipAPI.tsx deleted file mode 100644 index 7d2332ac7322e..0000000000000 --- a/packages/ui-voip/src/hooks/useVoipAPI.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useContext, useMemo } from 'react'; - -import type { VoipContextReady } from '../contexts/VoipContext'; -import { VoipContext, isVoipContextReady } from '../contexts/VoipContext'; - -type VoipAPI = { - makeCall(calleeURI: string): void; - endCall(): void; - register(): Promise; - unregister(): Promise; - openDialer(): void; - closeDialer(): void; - onRegisteredOnce(cb: () => void): () => void; - onUnregisteredOnce(cb: () => void): () => void; - transferCall(calleeURL: string): Promise; - changeAudioOutputDevice: VoipContextReady['changeAudioOutputDevice']; - changeAudioInputDevice: VoipContextReady['changeAudioInputDevice']; -}; - -const NOOP = (..._args: any[]): any => undefined; - -export const useVoipAPI = (): VoipAPI => { - const context = useContext(VoipContext); - - return useMemo(() => { - if (!isVoipContextReady(context)) { - return { - makeCall: NOOP, - endCall: NOOP, - register: NOOP, - unregister: NOOP, - openDialer: NOOP, - closeDialer: NOOP, - transferCall: NOOP, - changeAudioInputDevice: NOOP, - changeAudioOutputDevice: NOOP, - onRegisteredOnce: NOOP, - onUnregisteredOnce: NOOP, - } as VoipAPI; - } - - const { voipClient, changeAudioInputDevice, changeAudioOutputDevice } = context; - - return { - makeCall: voipClient.call, - endCall: voipClient.endCall, - register: voipClient.register, - unregister: voipClient.unregister, - transferCall: voipClient.transfer, - openDialer: () => voipClient.notifyDialer({ open: true }), - closeDialer: () => voipClient.notifyDialer({ open: false }), - changeAudioInputDevice, - changeAudioOutputDevice, - onRegisteredOnce: (cb: () => void) => voipClient.once('registered', cb), - onUnregisteredOnce: (cb: () => void) => voipClient.once('unregistered', cb), - }; - }, [context]); -}; diff --git a/packages/ui-voip/src/hooks/useVoipClient.tsx b/packages/ui-voip/src/hooks/useVoipClient.tsx deleted file mode 100644 index c494063c91c87..0000000000000 --- a/packages/ui-voip/src/hooks/useVoipClient.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useUser, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; -import { useEffect, useRef } from 'react'; - -import { useIceServers } from './useIceServers'; -import VoipClient from '../lib/VoipClient'; - -type VoipClientParams = { - enabled?: boolean; - autoRegister?: boolean; -}; - -type VoipClientResult = { - voipClient: VoipClient | null; - error: Error | null; -}; - -export const useVoipClient = ({ enabled = true, autoRegister = true }: VoipClientParams = {}): VoipClientResult => { - const { _id: userId } = useUser() || {}; - const voipClientRef = useRef(null); - const siteUrl = useSetting('Site_Url') as string; - - const getRegistrationInfo = useEndpoint('GET', '/v1/voip-freeswitch.extension.getRegistrationInfoByUserId'); - const iceGatheringTimeout = useSetting('VoIP_TeamCollab_Ice_Gathering_Timeout', 5000); - - const iceServers = useIceServers(); - - const { data: voipClient, error } = useQuery({ - queryKey: ['voip-client', enabled, userId, iceServers], - queryFn: async () => { - if (voipClientRef.current) { - voipClientRef.current.clear(); - } - - if (!userId) { - throw Error('error-user-not-found'); - } - - const registrationInfo = await getRegistrationInfo({ userId }) - .then((registration) => { - if (!registration) { - throw Error('error-registration-not-found'); - } - - return registration; - }) - .catch((e) => { - throw Error(e.error || 'error-registration-not-found'); - }); - - const { - extension: { extension }, - credentials: { websocketPath, password }, - } = registrationInfo; - - const config = { - iceServers, - authUserName: extension, - authPassword: password, - sipRegistrarHostnameOrIP: new URL(websocketPath).host, - webSocketURI: websocketPath, - connectionRetryCount: Number(10), // TODO: get from settings - enableKeepAliveUsingOptionsForUnstableNetworks: true, // TODO: get from settings - userId, - siteUrl, - iceGatheringTimeout, - }; - - const voipClient = await VoipClient.create(config); - - if (autoRegister) { - voipClient.register(); - } - - return voipClient; - }, - initialData: null, - enabled, - }); - - useEffect(() => { - voipClientRef.current = voipClient; - - return () => voipClientRef.current?.clear(); - }, [voipClient]); - - return { voipClient, error }; -}; diff --git a/packages/ui-voip/src/hooks/useVoipContactId.tsx b/packages/ui-voip/src/hooks/useVoipContactId.tsx deleted file mode 100644 index 94d9bb72b2587..0000000000000 --- a/packages/ui-voip/src/hooks/useVoipContactId.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { VoipSession } from '../definitions'; -import { useVoipExtensionDetails } from './useVoipExtensionDetails'; - -export const useVoipContactId = ({ session, transferEnabled = true }: { session: VoipSession; transferEnabled?: boolean }) => { - const { data: contact, isPending: isLoading } = useVoipExtensionDetails({ extension: session.contact.id }); - const { data: transferedByContact } = useVoipExtensionDetails({ - extension: session.transferedBy?.id, - enabled: transferEnabled, - }); - - const getContactName = (data: ReturnType['data'], defaultValue?: string) => { - const { name, username = '', callerName, callerNumber, extension } = data || {}; - return name || callerName || username || callerNumber || extension || defaultValue || ''; - }; - - const name = getContactName(contact, session.contact.name || session.contact.id); - const transferedBy = getContactName(transferedByContact, transferEnabled ? session.transferedBy?.id : ''); - - return { - name, - username: contact?.username, - transferedBy, - isLoading, - }; -}; diff --git a/packages/ui-voip/src/hooks/useVoipDialer.tsx b/packages/ui-voip/src/hooks/useVoipDialer.tsx deleted file mode 100644 index 0714badacb55d..0000000000000 --- a/packages/ui-voip/src/hooks/useVoipDialer.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useVoipAPI } from './useVoipAPI'; -import { useVoipEvent } from './useVoipEvent'; - -export const useVoipDialer = () => { - const { openDialer, closeDialer } = useVoipAPI(); - const { open } = useVoipEvent('dialer', { open: false }); - - return { - open, - openDialer: openDialer || (() => undefined), - closeDialer: closeDialer || (() => undefined), - }; -}; diff --git a/packages/ui-voip/src/hooks/useVoipEffect.tsx b/packages/ui-voip/src/hooks/useVoipEffect.tsx deleted file mode 100644 index d23c1d74f83bf..0000000000000 --- a/packages/ui-voip/src/hooks/useVoipEffect.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useCallback, useContext, useRef, useSyncExternalStore } from 'react'; - -import { VoipContext } from '../contexts/VoipContext'; -import type VoIPClient from '../lib/VoipClient'; - -export const useVoipEffect = (transform: (voipClient: VoIPClient) => T, initialValue: T) => { - const { voipClient } = useContext(VoipContext); - const stateRef = useRef(initialValue); - const transformFn = useRef(transform); - - const getSnapshot = useCallback(() => stateRef.current, []); - - const subscribe = useCallback( - (cb: () => void) => { - if (!voipClient) return () => undefined; - - stateRef.current = transformFn.current(voipClient); - return voipClient.on('stateChanged', (): void => { - stateRef.current = transformFn.current(voipClient); - cb(); - }); - }, - [voipClient], - ); - - return useSyncExternalStore(subscribe, getSnapshot); -}; diff --git a/packages/ui-voip/src/hooks/useVoipEvent.tsx b/packages/ui-voip/src/hooks/useVoipEvent.tsx deleted file mode 100644 index d137f64513fb3..0000000000000 --- a/packages/ui-voip/src/hooks/useVoipEvent.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useContext, useMemo, useRef, useSyncExternalStore } from 'react'; - -import { VoipContext } from '../contexts/VoipContext'; -import type { VoipEvents } from '../lib/VoipClient'; - -export const useVoipEvent = (eventName: E, initialValue: VoipEvents[E]) => { - const { voipClient } = useContext(VoipContext); - const initValue = useRef(initialValue); - - const [subscribe, getSnapshot] = useMemo(() => { - let state: VoipEvents[E] = initValue.current; - - const getSnapshot = (): VoipEvents[E] => state; - const callback = (cb: () => void) => { - if (!voipClient) return () => undefined; - - return voipClient.on(eventName, (event?: VoipEvents[E]): void => { - state = event as VoipEvents[E]; - cb(); - }); - }; - - return [callback, getSnapshot]; - }, [eventName, voipClient]); - - return useSyncExternalStore(subscribe, getSnapshot); -}; diff --git a/packages/ui-voip/src/hooks/useVoipExtensionDetails.tsx b/packages/ui-voip/src/hooks/useVoipExtensionDetails.tsx deleted file mode 100644 index 04a95181b16d4..0000000000000 --- a/packages/ui-voip/src/hooks/useVoipExtensionDetails.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; - -export const useVoipExtensionDetails = ({ extension, enabled = true }: { extension: string | undefined; enabled?: boolean }) => { - const isEnabled = !!extension && enabled; - const getContactDetails = useEndpoint('GET', '/v1/voip-freeswitch.extension.getDetails'); - const { data, ...result } = useQuery({ - queryKey: ['voip', 'voip-extension-details', extension, getContactDetails], - queryFn: () => getContactDetails({ extension: extension as string }), - enabled: isEnabled - }); - - return { - data: isEnabled ? data : undefined, - ...result, - }; -}; diff --git a/packages/ui-voip/src/hooks/useVoipSession.tsx b/packages/ui-voip/src/hooks/useVoipSession.tsx deleted file mode 100644 index 9ba0874f6d10a..0000000000000 --- a/packages/ui-voip/src/hooks/useVoipSession.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { VoipSession } from '../definitions'; -import { useVoipEffect } from './useVoipEffect'; - -export const useVoipSession = (): VoipSession | null => { - return useVoipEffect((client) => client.getSession(), null); -}; diff --git a/packages/ui-voip/src/hooks/useVoipState.tsx b/packages/ui-voip/src/hooks/useVoipState.tsx deleted file mode 100644 index b55f679dcef42..0000000000000 --- a/packages/ui-voip/src/hooks/useVoipState.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useContext, useMemo } from 'react'; - -import { useVoipEffect } from './useVoipEffect'; -import { VoipContext } from '../contexts/VoipContext'; - -export type VoipState = { - isEnabled: boolean; - isRegistered: boolean; - isReady: boolean; - isOnline: boolean; - isIncoming: boolean; - isOngoing: boolean; - isOutgoing: boolean; - isInCall: boolean; - isError: boolean; - error?: Error | null; - clientError?: Error | null; - isReconnecting: boolean; -}; - -const DEFAULT_STATE = { - isRegistered: false, - isReady: false, - isInCall: false, - isOnline: false, - isIncoming: false, - isOngoing: false, - isOutgoing: false, - isError: false, - isReconnecting: false, -}; - -export const useVoipState = (): VoipState => { - const { isEnabled, error: clientError } = useContext(VoipContext); - - const callState = useVoipEffect((client) => client.getState(), DEFAULT_STATE); - - return useMemo( - () => ({ - ...callState, - clientError, - isEnabled, - isError: !!clientError || callState.isError, - }), - [clientError, isEnabled, callState], - ); -}; diff --git a/packages/ui-voip/src/hooks/useVoipTransferModal.tsx b/packages/ui-voip/src/hooks/useVoipTransferModal.tsx deleted file mode 100644 index 0471a55d86c42..0000000000000 --- a/packages/ui-voip/src/hooks/useVoipTransferModal.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; -import { useMutation } from '@tanstack/react-query'; -import { useCallback, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; - -import VoipTransferModal from '../components/VoipTransferModal'; -import type { VoipOngoingSession } from '../definitions'; -import { useVoipAPI } from './useVoipAPI'; - -type UseVoipTransferParams = { - session: VoipOngoingSession; -}; - -export const useVoipTransferModal = ({ session }: UseVoipTransferParams) => { - const { t } = useTranslation(); - const setModal = useSetModal(); - const dispatchToastMessage = useToastMessageDispatch(); - const { transferCall } = useVoipAPI(); - - const close = useCallback(() => setModal(null), [setModal]); - - useEffect(() => () => close(), [close]); - - const handleTransfer = useMutation({ - mutationFn: async ({ extension, name }: { extension: string; name: string | undefined }) => { - await transferCall(extension); - return name || extension; - }, - onSuccess: (name: string) => { - dispatchToastMessage({ type: 'success', message: t('Call_transfered_to__name__', { name }) }); - close(); - }, - onError: () => { - dispatchToastMessage({ type: 'error', message: t('Failed_to_transfer_call') }); - close(); - }, - }); - - const startTransfer = useCallback(() => { - setModal( - setModal(null)} - onConfirm={handleTransfer.mutate} - />, - ); - }, [handleTransfer.isPending, handleTransfer.mutate, session, setModal]); - - return { startTransfer, cancelTransfer: close }; -}; diff --git a/packages/ui-voip/src/index.ts b/packages/ui-voip/src/index.ts index c64877843b3ad..d28ae53a6c93b 100644 --- a/packages/ui-voip/src/index.ts +++ b/packages/ui-voip/src/index.ts @@ -1,8 +1,5 @@ -export { default as VoipProvider } from './providers/VoipProvider'; export { default as MediaCallProvider } from './v2/MediaCallProvider'; -export * from './definitions/VoipSession'; export * from './hooks'; -export * from './components'; export { MediaCallContext, useMediaCallExternalContext as useMediaCallContext, useMediaCallAction, type PeerInfo } from './v2'; diff --git a/packages/ui-voip/src/lib/LocalStream.ts b/packages/ui-voip/src/lib/LocalStream.ts deleted file mode 100644 index 10d6c251db9c5..0000000000000 --- a/packages/ui-voip/src/lib/LocalStream.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * This class is used for local stream manipulation. - * @remarks - * This class does not really store any local stream for the reason - * that the local stream tracks are stored in the peer connection. - * - * This simply provides necessary methods for stream manipulation. - * - * Currently it does not use any of its base functionality. Nevertheless - * there might be a need that we may want to do some stream operations - * such as closing of tracks, in future. For that purpose, it is written - * this way. - * - */ - -import type { Session } from 'sip.js'; -import type { MediaStreamFactory, SessionDescriptionHandler } from 'sip.js/lib/platform/web'; -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 { - const factory: MediaStreamFactory = defaultMediaStreamFactory(); - if (session?.sessionDescriptionHandler) { - return factory(constraints, session.sessionDescriptionHandler as SessionDescriptionHandler); - } - } - - static async replaceTrack(peerConnection: RTCPeerConnection, newStream: MediaStream, mediaType?: 'audio' | 'video'): Promise { - const senders = peerConnection.getSenders(); - if (!senders) { - return false; - } - /** - * This will be called when media device change happens. - * This needs to be called externally when the device change occurs. - * This function first acquires the new stream based on device selection - * and then replaces the track in the sender of existing stream by track acquired - * by caputuring new stream. - * - * Notes: - * Each sender represents a track in the RTCPeerConnection. - * Peer connection will contain single track for - * each, audio, video and data. - * Furthermore, We are assuming that - * newly captured stream will have a single track for each media type. i.e - * audio video and data. But this assumption may not be true atleast in theory. One may see multiple - * audio track in the captured stream or multiple senders for same kind in the peer connection - * If/When such situation arrives in future, we may need to revisit the track replacement logic. - * */ - - switch (mediaType) { - case 'audio': { - let replaced = false; - const newTracks = newStream.getAudioTracks(); - if (!newTracks) { - console.warn('replaceTrack() : No audio tracks in the stream. Returning'); - return false; - } - for (let i = 0; i < senders?.length; i++) { - if (senders[i].track?.kind === 'audio') { - senders[i].replaceTrack(newTracks[0]); - replaced = true; - break; - } - } - return replaced; - } - case 'video': { - let replaced = false; - const newTracks = newStream.getVideoTracks(); - if (!newTracks) { - console.warn('replaceTrack() : No video tracks in the stream. Returning'); - return false; - } - for (let i = 0; i < senders?.length; i++) { - if (senders[i].track?.kind === 'video') { - senders[i].replaceTrack(newTracks[0]); - replaced = true; - break; - } - } - return replaced; - } - default: { - let replaced = false; - const newTracks = newStream.getVideoTracks(); - if (!newTracks) { - console.warn('replaceTrack() : No tracks in the stream. Returning'); - return false; - } - for (let i = 0; i < senders?.length; i++) { - for (let j = 0; j < newTracks.length; j++) { - if (senders[i].track?.kind === newTracks[j].kind) { - senders[i].replaceTrack(newTracks[j]); - replaced = true; - break; - } - } - } - return replaced; - } - } - } -} diff --git a/packages/ui-voip/src/lib/RemoteStream.ts b/packages/ui-voip/src/lib/RemoteStream.ts deleted file mode 100644 index b870cb2bae4f9..0000000000000 --- a/packages/ui-voip/src/lib/RemoteStream.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * This class is used for local stream manipulation. - * @remarks - * This class wraps up browser media stream and HTMLMedia element - * and takes care of rendering the media on a given element. - * This provides enough abstraction so that the higher level - * classes do not need to know about the browser specificities for - * media. - * This will also provide stream related functionalities such as - * mixing of 2 streams in to 2, adding/removing tracks, getting a track information - * detecting voice energy etc. Which will be implemented as when needed - */ - -import Stream from './Stream'; - -export default class RemoteStream extends Stream { - private renderingMediaElement: HTMLMediaElement | undefined; - - constructor(mediaStream: MediaStream) { - super(mediaStream); - } - - /** - * Called for initializing the class - * @remarks - */ - - init(rmElement: HTMLMediaElement): void { - if (this.renderingMediaElement) { - // Someone already has setup the stream and initializing it once again - // Clear the existing stream object - this.renderingMediaElement.pause(); - this.renderingMediaElement.srcObject = null; - } - this.renderingMediaElement = rmElement; - } - - /** - * Called for playing the stream - * @remarks - * Plays the stream on media element. Stream will be autoplayed and muted based on the settings. - * throws and error if the play fails. - */ - - play(autoPlay = true, muteAudio = false): void { - if (this.renderingMediaElement && this.mediaStream) { - this.renderingMediaElement.autoplay = autoPlay; - this.renderingMediaElement.srcObject = this.mediaStream; - if (autoPlay) { - this.renderingMediaElement.play().catch((error: Error) => { - throw error; - }); - } - if (muteAudio) { - this.renderingMediaElement.volume = 0; - } - } - } - - /** - * Called for pausing the stream - * @remarks - */ - pause(): void { - this.renderingMediaElement?.pause(); - } - - clear(): void { - super.clear(); - if (this.renderingMediaElement) { - this.renderingMediaElement.pause(); - this.renderingMediaElement.srcObject = null; - } - } -} diff --git a/packages/ui-voip/src/lib/Stream.ts b/packages/ui-voip/src/lib/Stream.ts deleted file mode 100644 index 8473b8558db2a..0000000000000 --- a/packages/ui-voip/src/lib/Stream.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * This class is used for stream manipulation. - * @remarks - * This class wraps up browser media stream and HTMLMedia element - * and takes care of rendering the media on a given element. - * This provides enough abstraction so that the higher level - * classes do not need to know about the browser specificities for - * media. - * This will also provide stream related functionalities such as - * mixing of 2 streams in to 2, adding/removing tracks, getting a track information - * detecting voice energy etc. Which will be implemented as when needed - */ -export default class Stream { - protected mediaStream: MediaStream | undefined; - - constructor(mediaStream: MediaStream) { - this.mediaStream = mediaStream; - } - /** - * Called for stopping the tracks in a given stream. - * @remarks - * All the tracks from a given stream will be stopped. - */ - - private stopTracks(): void { - const tracks = this.mediaStream?.getTracks(); - if (tracks) { - for (let i = 0; i < tracks?.length; i++) { - tracks[i].stop(); - } - } - } - - /** - * Called for setting the callback when the track gets added - * @remarks - */ - - onTrackAdded(callBack: any): void { - this.mediaStream?.onaddtrack?.(callBack); - } - - /** - * Called for setting the callback when the track gets removed - * @remarks - */ - - onTrackRemoved(callBack: any): void { - this.mediaStream?.onremovetrack?.(callBack); - } - - /** - * Called for clearing the streams and media element. - * @remarks - * This function stops the media element play, clears the srcObject - * stops all the tracks in the stream and sets media stream to undefined. - * This function ususally gets called when call ends or to clear the previous stream - * when the stream is switched to another stream. - */ - - clear(): void { - if (this.mediaStream) { - this.stopTracks(); - this.mediaStream = undefined; - } - } -} diff --git a/packages/ui-voip/src/lib/VoipClient.ts b/packages/ui-voip/src/lib/VoipClient.ts deleted file mode 100644 index 5e9949b319720..0000000000000 --- a/packages/ui-voip/src/lib/VoipClient.ts +++ /dev/null @@ -1,950 +0,0 @@ -import type { SignalingSocketEvents, VoipEvents as CoreVoipEvents, VoIPUserConfiguration } from '@rocket.chat/core-typings'; -import { Emitter } from '@rocket.chat/emitter'; -import type { InvitationAcceptOptions, Message, Referral, Session, SessionInviteOptions, Cancel as SipCancel } from 'sip.js'; -import { Registerer, RequestPendingError, SessionState, UserAgent, Invitation, Inviter, RegistererState, UserAgentState } from 'sip.js'; -import type { IncomingResponse, OutgoingByeRequest, URI } from 'sip.js/lib/core'; -import type { SessionDescriptionHandlerOptions } from 'sip.js/lib/platform/web'; -import { SessionDescriptionHandler } from 'sip.js/lib/platform/web'; - -import type { ContactInfo, VoipSession } from '../definitions'; -import LocalStream from './LocalStream'; -import RemoteStream from './RemoteStream'; -import { getMainInviteRejectionReason } from './getMainInviteRejectionReason'; - -export type VoipEvents = Omit & { - callestablished: ContactInfo; - incomingcall: ContactInfo; - outgoingcall: ContactInfo; - dialer: { open: boolean }; - incomingcallerror: string; -}; - -type SessionError = { - status: number | undefined; - reason: string; - contact: ContactInfo; -}; - -class VoipClient extends Emitter { - protected registerer: Registerer | undefined; - - protected session: Session | undefined; - - public userAgent: UserAgent | undefined; - - public networkEmitter: Emitter; - - private audioElement: HTMLAudioElement | null = null; - - private remoteStream: RemoteStream | undefined; - - private held = false; - - private muted = false; - - private online = true; - - private error: SessionError | null = null; - - private contactInfo: ContactInfo | null = null; - - private reconnecting = false; - - private contactName: string | null = null; - - constructor(private readonly config: VoIPUserConfiguration) { - super(); - - this.networkEmitter = new Emitter(); - } - - public async init() { - const { authPassword, authUserName, sipRegistrarHostnameOrIP, iceServers, webSocketURI, iceGatheringTimeout } = this.config; - - const transportOptions = { - server: webSocketURI, - connectionTimeout: 100, - keepAliveInterval: 20, - }; - - const sdpFactoryOptions = { - ...(typeof iceGatheringTimeout === 'number' && { iceGatheringTimeout }), - peerConnectionConfiguration: { iceServers }, - }; - - const searchParams = new URLSearchParams(window.location.search); - const debug = Boolean(searchParams.get('debug') || searchParams.get('debug-voip')); - - this.userAgent = new UserAgent({ - contactName: this.getContactName(), - viaHost: this.getContactHostName(), - authorizationPassword: authPassword, - authorizationUsername: authUserName, - uri: UserAgent.makeURI(`sip:${authUserName}@${sipRegistrarHostnameOrIP}`), - transportOptions, - sessionDescriptionHandlerFactoryOptions: sdpFactoryOptions, - logConfiguration: false, - logLevel: debug ? 'debug' : 'error', - delegate: { - onInvite: this.onIncomingCall, - onRefer: this.onTransferedCall, - onMessage: this.onMessageReceived, - }, - }); - - this.userAgent.transport.isConnected(); - - try { - this.registerer = new Registerer(this.userAgent); - - this.userAgent.transport.onConnect = this.onUserAgentConnected; - this.userAgent.transport.onDisconnect = this.onUserAgentDisconnected; - - await this.userAgent.start(); - - window.addEventListener('online', this.onNetworkRestored); - window.addEventListener('offline', this.onNetworkLost); - } catch (error) { - throw error; - } - } - - static async create(config: VoIPUserConfiguration): Promise { - const voip = new VoipClient(config); - await voip.init(); - return voip; - } - - protected initSession(session: Session): void { - this.session = session; - - this.updateContactInfoFromSession(session); - - this.session?.stateChange.addListener((state: SessionState) => { - if (this.session !== session) { - return; // if our session has changed, just return - } - - const sessionEvents: Record void> = { - [SessionState.Initial]: () => undefined, // noop - [SessionState.Establishing]: this.onSessionStablishing, - [SessionState.Established]: this.onSessionStablished, - [SessionState.Terminating]: this.onSessionTerminated, - [SessionState.Terminated]: this.onSessionTerminated, - } as const; - - const event = sessionEvents[state]; - - if (!event) { - throw new Error('Unknown session state.'); - } - - event(); - }); - } - - public register = async (): Promise => { - await this.registerer?.register({ - requestDelegate: { - onAccept: this.onRegistrationAccepted, - onReject: this.onRegistrationRejected, - }, - }); - }; - - public unregister = async (): Promise => { - await this.registerer?.unregister({ - all: true, - requestDelegate: { - onAccept: this.onUnregistrationAccepted, - onReject: this.onUnregistrationRejected, - }, - }); - }; - - public call = async (calleeURI: string): Promise => { - if (!calleeURI) { - throw new Error('Invalid URI'); - } - - if (this.session) { - throw new Error('Session already exists'); - } - - if (!this.userAgent) { - throw new Error('No User Agent.'); - } - - const target = this.makeURI(calleeURI); - - if (!target) { - throw new Error(`Failed to create valid URI ${calleeURI}`); - } - - const inviter = new Inviter(this.userAgent, target, { - sessionDescriptionHandlerOptions: { - constraints: { - audio: true, - video: false, - }, - }, - }); - - await this.sendInvite(inviter); - }; - - public transfer = async (calleeURI: string): Promise => { - if (!calleeURI) { - throw new Error('Invalid URI'); - } - - if (!this.session) { - throw new Error('No active call'); - } - - if (!this.userAgent) { - throw new Error('No User Agent.'); - } - - const target = this.makeURI(calleeURI); - - if (!target) { - throw new Error(`Failed to create valid URI ${calleeURI}`); - } - - await this.session.refer(target, { - requestDelegate: { - onAccept: () => this.sendContactUpdateMessage(target), - }, - }); - }; - - public answer = (): Promise => { - if (!(this.session instanceof Invitation)) { - throw new Error('Session not instance of Invitation.'); - } - - const invitationAcceptOptions: InvitationAcceptOptions = { - sessionDescriptionHandlerOptions: { - constraints: { - audio: true, - video: false, - }, - }, - }; - - return this.session.accept(invitationAcceptOptions); - }; - - public reject = (): Promise => { - if (!this.session) { - return Promise.reject(new Error('No active call.')); - } - - if (!(this.session instanceof Invitation)) { - return Promise.reject(new Error('Session not instance of Invitation.')); - } - - return this.session.reject(); - }; - - public endCall = async (): Promise => { - if (!this.session) { - return Promise.reject(new Error('No active call.')); - } - - switch (this.session.state) { - case SessionState.Initial: - case SessionState.Establishing: - if (this.session instanceof Inviter) { - return this.session.cancel(); - } - - if (this.session instanceof Invitation) { - return this.session.reject(); - } - - throw new Error('Unknown session type.'); - case SessionState.Established: - return this.session.bye(); - case SessionState.Terminating: - console.warn('Trying to end a call that is already Terminating.'); - break; - case SessionState.Terminated: - console.warn('Trying to end a call that is already Terminated.'); - break; - default: - throw new Error('Unknown state'); - } - - return Promise.resolve(); - }; - - public setMute = async (mute: boolean): Promise => { - if (this.muted === mute) { - return Promise.resolve(); - } - - if (!this.session) { - throw new Error('No active call.'); - } - - const { peerConnection } = this.sessionDescriptionHandler; - - if (!peerConnection) { - throw new Error('Peer connection closed.'); - } - - const enableTracks = !mute; - this.toggleMediaStreamTracks('sender', enableTracks); - this.muted = mute; - this.emit('stateChanged'); - }; - - public setHold = async (hold: boolean): Promise => { - if (this.held === hold) { - return Promise.resolve(); - } - - if (!this.session) { - throw new Error('Session not found'); - } - - const { sessionDescriptionHandler } = this; - - const sessionDescriptionHandlerOptions = this.session.sessionDescriptionHandlerOptionsReInvite as SessionDescriptionHandlerOptions; - sessionDescriptionHandlerOptions.hold = hold; - this.session.sessionDescriptionHandlerOptionsReInvite = sessionDescriptionHandlerOptions; - - const { peerConnection } = sessionDescriptionHandler; - - if (!peerConnection) { - throw new Error('Peer connection closed.'); - } - - try { - const options: SessionInviteOptions = { - requestDelegate: { - onAccept: (): void => { - this.held = hold; - - this.toggleMediaStreamTracks('receiver', !this.held); - this.toggleMediaStreamTracks('sender', !this.held); - - this.held ? this.emit('hold') : this.emit('unhold'); - this.emit('stateChanged'); - }, - onReject: (): void => { - this.toggleMediaStreamTracks('receiver', !this.held); - this.toggleMediaStreamTracks('sender', !this.held); - this.emit('holderror'); - }, - }, - }; - - await this.session.invite(options); - - this.toggleMediaStreamTracks('receiver', !hold); - this.toggleMediaStreamTracks('sender', !hold); - } catch (error: unknown) { - if (error instanceof RequestPendingError) { - console.error(`[${this.session?.id}] A hold request is already in progress.`); - } - - this.emit('holderror'); - throw error; - } - }; - - public sendDTMF = (tone: string): Promise => { - // Validate tone - if (!tone || !/^[0-9A-D#*,]$/.exec(tone)) { - return Promise.reject(new Error('Invalid DTMF tone.')); - } - - if (!this.session) { - return Promise.reject(new Error('Session does not exist.')); - } - - const dtmf = tone; - const duration = 2000; - const body = { - contentDisposition: 'render', - contentType: 'application/dtmf-relay', - content: `Signal=${dtmf}\r\nDuration=${duration}`, - }; - const requestOptions = { body }; - - return this.session.info({ requestOptions }).then(() => undefined); - }; - - private async attemptReconnection(reconnectionAttempt = 0, checkRegistration = false): Promise { - const { connectionRetryCount } = this.config; - - if (!this.userAgent) { - return; - } - - if (connectionRetryCount !== -1 && reconnectionAttempt > connectionRetryCount) { - console.error('VoIP reconnection limit reached.'); - this.reconnecting = false; - this.emit('stateChanged'); - return; - } - - if (!this.reconnecting) { - this.reconnecting = true; - this.emit('stateChanged'); - } - - const reconnectionDelay = Math.pow(2, reconnectionAttempt % 4); - - console.error(`Attempting to reconnect with backoff due to network loss. Backoff time [${reconnectionDelay}]`); - setTimeout(() => { - this.userAgent?.reconnect().catch(() => { - this.attemptReconnection(++reconnectionAttempt, checkRegistration); - }); - }, reconnectionDelay * 1000); - } - - public async changeAudioInputDevice(constraints: MediaStreamConstraints): Promise { - if (!this.session) { - LocalStream.changeInputDeviceOffline(constraints); - return false; - } - - const newStream = await LocalStream.requestNewStream(constraints, this.session); - - if (!newStream) { - console.warn('changeAudioInputDevice() : Unable to get local stream.'); - return false; - } - - const { peerConnection } = this.sessionDescriptionHandler; - - if (!peerConnection) { - console.warn('changeAudioInputDevice() : No peer connection.'); - return false; - } - - LocalStream.replaceTrack(peerConnection, newStream, 'audio'); - return true; - } - - public switchAudioElement(audioElement: HTMLAudioElement | null): void { - this.audioElement = audioElement; - - if (this.remoteStream) { - this.playRemoteStream(); - } - } - - private setContactInfo(contact: ContactInfo) { - this.contactInfo = contact; - this.emit('stateChanged'); - } - - public getContactInfo() { - if (this.error) { - return this.error.contact; - } - - if (!(this.session instanceof Invitation) && !(this.session instanceof Inviter)) { - return null; - } - - return this.contactInfo; - } - - public getReferredBy() { - if (!(this.session instanceof Invitation)) { - return null; - } - - const referredBy = this.session.request.getHeader('Referred-By'); - - if (!referredBy) { - return null; - } - - const uri = UserAgent.makeURI(referredBy.slice(1, -1)); - - if (!uri) { - return null; - } - - return { - id: uri.user ?? '', - host: uri.host, - }; - } - - public isRegistered(): boolean { - return this.registerer?.state === RegistererState.Registered; - } - - public isReady(): boolean { - return this.userAgent?.state === UserAgentState.Started; - } - - public isCaller(): boolean { - return this.session instanceof Inviter; - } - - public isCallee(): boolean { - return this.session instanceof Invitation; - } - - public isIncoming(): boolean { - return this.getSessionType() === 'INCOMING'; - } - - public isOngoing(): boolean { - return this.getSessionType() === 'ONGOING'; - } - - public isOutgoing(): boolean { - return this.getSessionType() === 'OUTGOING'; - } - - public isInCall(): boolean { - return this.getSessionType() !== null; - } - - public isError(): boolean { - return !!this.error; - } - - public isReconnecting(): boolean { - return this.reconnecting; - } - - public isOnline(): boolean { - return this.online; - } - - public isMuted(): boolean { - return this.muted; - } - - public isHeld(): boolean { - return this.held; - } - - public getError() { - return this.error ?? null; - } - - public clearErrors = (): void => { - this.setError(null); - }; - - public getSessionType(): VoipSession['type'] | null { - if (this.error) { - return 'ERROR'; - } - - if (this.session?.state === SessionState.Established) { - return 'ONGOING'; - } - - if (this.session instanceof Invitation) { - return 'INCOMING'; - } - - if (this.session instanceof Inviter) { - return 'OUTGOING'; - } - - return null; - } - - public getSession(): VoipSession | null { - const type = this.getSessionType(); - - switch (type) { - case 'ERROR': { - const { contact, ...error } = this.getError() as SessionError; - return { - type: 'ERROR', - error, - contact, - end: this.clearErrors, - }; - } - case 'INCOMING': - case 'ONGOING': - case 'OUTGOING': - return { - type, - contact: this.getContactInfo() as ContactInfo, - transferedBy: this.getReferredBy(), - isMuted: this.isMuted(), - isHeld: this.isHeld(), - mute: this.setMute, - hold: this.setHold, - accept: this.answer, - end: this.endCall, - dtmf: this.sendDTMF, - }; - default: - return null; - } - } - - public getState() { - return { - isRegistered: this.isRegistered(), - isReady: this.isReady(), - isOnline: this.isOnline(), - isIncoming: this.isIncoming(), - isOngoing: this.isOngoing(), - isOutgoing: this.isOutgoing(), - isInCall: this.isInCall(), - isError: this.isError(), - isReconnecting: this.isReconnecting(), - }; - } - - public getAudioElement(): HTMLAudioElement | null { - return this.audioElement; - } - - public notifyDialer(value: { open: boolean }) { - this.emit('dialer', value); - } - - public clear(): void { - this.userAgent?.stop(); - this.registerer?.dispose(); - - if (this.userAgent) { - this.userAgent.transport.onConnect = undefined; - this.userAgent.transport.onDisconnect = undefined; - window.removeEventListener('online', this.onNetworkRestored); - window.removeEventListener('offline', this.onNetworkLost); - } - } - - private setupRemoteMedia() { - const { remoteMediaStream } = this.sessionDescriptionHandler; - - this.remoteStream = new RemoteStream(remoteMediaStream); - this.playRemoteStream(); - } - - private playRemoteStream() { - if (!this.remoteStream) { - console.warn(`Attempted to play missing remote media.`); - return; - } - - if (!this.audioElement) { - console.error('Unable to play remote media: VoIPClient is missing an AudioElement reference to play it on.'); - return; - } - - this.remoteStream.init(this.audioElement); - this.remoteStream.play(); - } - - private makeURI(calleeURI: string): URI | undefined { - const hasPlusChar = calleeURI.includes('+'); - return UserAgent.makeURI(`sip:${hasPlusChar ? '*' : ''}${calleeURI}@${this.config.sipRegistrarHostnameOrIP}`); - } - - private toggleMediaStreamTracks(type: 'sender' | 'receiver', enable: boolean): void { - const { peerConnection } = this.sessionDescriptionHandler; - - if (!peerConnection) { - throw new Error('Peer connection closed.'); - } - - const tracks = type === 'sender' ? peerConnection.getSenders() : peerConnection.getReceivers(); - - tracks?.forEach((sender) => { - if (sender.track) { - sender.track.enabled = enable; - } - }); - } - - private async sendInvite(inviter: Inviter): Promise { - this.initSession(inviter); - - await inviter.invite({ - requestDelegate: { - onReject: this.onInviteRejected, - }, - }); - - this.emit('stateChanged'); - } - - private updateContactInfoFromMessage(message: Message): void { - const contentType = message.request.getHeader('Content-Type'); - const messageType = message.request.getHeader('X-Message-Type'); - - try { - if (messageType !== 'contactUpdate' || contentType !== 'application/json') { - throw new Error('Failed to parse contact update message'); - } - - const data = JSON.parse(message.request.body); - const uri = UserAgent.makeURI(data.uri); - - if (!uri) { - throw new Error('Failed to parse contact update message'); - } - - this.setContactInfo({ - id: uri.user ?? '', - host: uri.host, - name: uri.user, - }); - } catch (e) { - const error = e as Error; - console.warn(error.message); - } - } - - private updateContactInfoFromSession(session: Session) { - if (!session) { - return; - } - - const { remoteIdentity } = session; - - this.setContactInfo({ - id: remoteIdentity.uri.user ?? '', - name: remoteIdentity.displayName, - host: remoteIdentity.uri.host, - }); - } - - private sendContactUpdateMessage(contactURI: URI) { - if (!this.session) { - return; - } - - this.session.message({ - requestOptions: { - extraHeaders: ['X-Message-Type: contactUpdate'], - body: { - contentDisposition: 'render', - contentType: 'application/json', - content: JSON.stringify({ uri: contactURI.toString() }), - }, - }, - }); - } - - private get sessionDescriptionHandler(): SessionDescriptionHandler { - if (!this.session) { - throw new Error('No active call.'); - } - - const { sessionDescriptionHandler } = this.session; - - if (!(sessionDescriptionHandler instanceof SessionDescriptionHandler)) { - throw new Error("Session's session description handler not instance of SessionDescriptionHandler."); - } - - return sessionDescriptionHandler; - } - - private setError(error: SessionError | null) { - console.error(error); - this.error = error; - this.emit('stateChanged'); - } - - private onUserAgentConnected = (): void => { - console.log('VoIP user agent connected.'); - - const wasReconnecting = this.reconnecting; - - this.reconnecting = false; - this.networkEmitter.emit('connected'); - this.emit('stateChanged'); - - if (!this.isReady() || !wasReconnecting) { - return; - } - - this.register() - .then(() => { - this.emit('stateChanged'); - }) - .catch((error?: any) => { - console.error('VoIP failed to register after user agent connection.'); - if (error) { - console.error(error); - } - }); - }; - - private onUserAgentDisconnected = (error: any): void => { - console.log('VoIP user agent disconnected.'); - - this.reconnecting = !!error; - this.networkEmitter.emit('disconnected'); - this.emit('stateChanged'); - - if (error) { - if (this.isRegistered()) { - this.unregister() - .then(() => { - this.emit('stateChanged'); - }) - .catch((error?: any) => { - console.error('VoIP failed to unregister after user agent disconnection.'); - if (error) { - console.error(error); - } - }); - } - - this.networkEmitter.emit('connectionerror', error); - this.attemptReconnection(); - } - }; - - private onRegistrationAccepted = (): void => { - this.emit('registered'); - this.emit('stateChanged'); - }; - - private onRegistrationRejected = (error: any): void => { - this.emit('registrationerror', error); - }; - - private onUnregistrationAccepted = (): void => { - this.emit('unregistered'); - this.emit('stateChanged'); - }; - - private onUnregistrationRejected = (error: any): void => { - this.emit('unregistrationerror', error); - }; - - private onInvitationCancel(invitation: Invitation, message: SipCancel): void { - const reason = getMainInviteRejectionReason(invitation, message); - if (reason) { - this.emit('incomingcallerror', reason); - } - } - - private onIncomingCall = async (invitation: Invitation): Promise => { - if (!this.isRegistered() || this.session) { - await invitation.reject(); - return; - } - - invitation.delegate = { - onCancel: (cancel: SipCancel) => this.onInvitationCancel(invitation, cancel), - }; - - this.initSession(invitation); - - this.emit('incomingcall', this.getContactInfo() as ContactInfo); - this.emit('stateChanged'); - }; - - private onTransferedCall = async (referral: Referral) => { - await referral.accept(); - this.sendInvite(referral.makeInviter()); - }; - - private onMessageReceived = async (message: Message): Promise => { - if (!message.request.hasHeader('X-Message-Type')) { - message.reject(); - return; - } - - const messageType = message.request.getHeader('X-Message-Type'); - - switch (messageType) { - case 'contactUpdate': - return this.updateContactInfoFromMessage(message); - } - }; - - private onSessionStablishing = (): void => { - this.emit('outgoingcall', this.getContactInfo() as ContactInfo); - }; - - private onSessionStablished = (): void => { - this.setupRemoteMedia(); - this.emit('callestablished', this.getContactInfo() as ContactInfo); - this.emit('stateChanged'); - }; - - private onInviteRejected = (response: IncomingResponse): void => { - const { statusCode, reasonPhrase, to } = response.message; - - if (!reasonPhrase || statusCode === 487) { - return; - } - - this.setError({ - status: statusCode, - reason: reasonPhrase, - contact: { id: to.uri.user ?? '', host: to.uri.host }, - }); - - this.emit('callfailed', response.message.reasonPhrase || 'unknown'); - }; - - private onSessionTerminated = (): void => { - this.session = undefined; - this.muted = false; - this.held = false; - this.remoteStream?.clear(); - this.emit('callterminated'); - this.emit('stateChanged'); - }; - - private onNetworkRestored = (): void => { - this.online = true; - this.networkEmitter.emit('localnetworkonline'); - this.emit('stateChanged'); - - this.attemptReconnection(); - }; - - private onNetworkLost = (): void => { - this.online = false; - this.networkEmitter.emit('localnetworkoffline'); - this.emit('stateChanged'); - }; - - private getContactHostName(): string | undefined { - try { - const url = new URL(this.config.siteUrl); - return url.hostname; - } catch { - return undefined; - } - } - - private createRandomToken(size: number): string { - let token = ''; - for (let i = 0; i < size; i++) { - const r = Math.floor(Math.random() * 32); - token += r.toString(32); - } - return token; - } - - private getContactName(): string { - if (!this.contactName) { - const randomName = this.createRandomToken(8); - this.contactName = `${this.config.authUserName}-${this.config.userId}-${randomName}`; - } - - return this.contactName; - } -} - -export default VoipClient; diff --git a/packages/ui-voip/src/lib/getMainInviteRejectionReason.spec.ts b/packages/ui-voip/src/lib/getMainInviteRejectionReason.spec.ts deleted file mode 100644 index 42187442f3ef3..0000000000000 --- a/packages/ui-voip/src/lib/getMainInviteRejectionReason.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { type Cancel as SipCancel, type Invitation, type SessionState } from 'sip.js'; - -import { getMainInviteRejectionReason } from './getMainInviteRejectionReason'; - -const mockInvitation = (state: SessionState[keyof SessionState]): Invitation => - ({ - state, - }) as any; - -const mockSipCancel = (reasons: string[]): SipCancel => - ({ - request: { - headers: { - Reason: reasons.map((raw) => ({ raw })), - }, - }, - }) as any; - -describe('getMainInviteRejectionReason', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should return undefined for natural endings', () => { - const result = getMainInviteRejectionReason(mockInvitation('Terminated'), mockSipCancel(['SIP ;cause=487 ;text="ORIGINATOR_CANCEL"'])); - expect(result).toBeUndefined(); - }); - - it('should return priorityErrorEndings if present', () => { - const result = getMainInviteRejectionReason( - mockInvitation('Terminated'), - mockSipCancel(['SIP ;cause=488 ;text="USER_NOT_REGISTERED"']), - ); - expect(result).toBe('USER_NOT_REGISTERED'); - }); - - it('should return the first parsed reason if call was canceled at the initial state', () => { - const result = getMainInviteRejectionReason( - mockInvitation('Initial'), - mockSipCancel(['text="UNEXPECTED_REASON"', 'text="ANOTHER_REASON"']), - ); - expect(result).toBe('UNEXPECTED_REASON'); - }); - - it('should log a warning if call was canceled for unexpected reason', () => { - console.warn = jest.fn(); - const result = getMainInviteRejectionReason( - mockInvitation('Terminated'), - mockSipCancel(['text="UNEXPECTED_REASON"', 'text="ANOTHER_REASON"']), - ); - expect(console.warn).toHaveBeenCalledWith('The call was canceled for an unexpected reason', ['UNEXPECTED_REASON', 'ANOTHER_REASON']); - expect(result).toBeUndefined(); - }); - - it('should handle empty parsed reasons array gracefully', () => { - const result = getMainInviteRejectionReason(mockInvitation('Terminated'), mockSipCancel([])); - expect(result).toBeUndefined(); - }); -}); diff --git a/packages/ui-voip/src/lib/getMainInviteRejectionReason.ts b/packages/ui-voip/src/lib/getMainInviteRejectionReason.ts deleted file mode 100644 index 23a7e6b2a1468..0000000000000 --- a/packages/ui-voip/src/lib/getMainInviteRejectionReason.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Cancel as SipCancel, Invitation } from 'sip.js'; - -import { parseInviteRejectionReasons } from './parseInviteRejectionReasons'; - -const naturalEndings = [ - 'ORIGINATOR_CANCEL', - 'NO_ANSWER', - 'NORMAL_CLEARING', - 'USER_BUSY', - 'NO_USER_RESPONSE', - 'NORMAL_UNSPECIFIED', -] as const; - -const priorityErrorEndings = ['USER_NOT_REGISTERED'] as const; - -export function getMainInviteRejectionReason(invitation: Invitation, message: SipCancel): string | undefined { - const parsedReasons = parseInviteRejectionReasons(message); - - for (const ending of naturalEndings) { - if (parsedReasons.includes(ending)) { - // Do not emit any errors for normal endings - return; - } - } - - for (const ending of priorityErrorEndings) { - if (parsedReasons.includes(ending)) { - // An error definitely happened - return ending; - } - } - - if (invitation?.state === 'Initial') { - // Call was canceled at the initial state and it was not due to one of the natural reasons, treat it as unexpected - return parsedReasons.shift(); - } - - console.warn('The call was canceled for an unexpected reason', parsedReasons); -} diff --git a/packages/ui-voip/src/lib/parseInviteRejectionReasons.spec.ts b/packages/ui-voip/src/lib/parseInviteRejectionReasons.spec.ts deleted file mode 100644 index 5184dd3c614db..0000000000000 --- a/packages/ui-voip/src/lib/parseInviteRejectionReasons.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { Cancel as SipCancel } from 'sip.js'; - -import { parseInviteRejectionReasons } from './parseInviteRejectionReasons'; - -describe('parseInviteRejectionReasons', () => { - it('should return an empty array when message is undefined', () => { - expect(parseInviteRejectionReasons(undefined as any)).toEqual([]); - }); - - it('should return an empty array when headers are not defined', () => { - const message: SipCancel = { request: {} } as any; - expect(parseInviteRejectionReasons(message)).toEqual([]); - }); - - it('should return an empty array when Reason header is not defined', () => { - const message: SipCancel = { request: { headers: {} } } as any; - expect(parseInviteRejectionReasons(message)).toEqual([]); - }); - - it('should parse a single text reason correctly', () => { - const message: SipCancel = { - request: { - headers: { - Reason: [{ raw: 'text="Busy Here"' }], - }, - }, - } as any; - - expect(parseInviteRejectionReasons(message)).toEqual(['Busy Here']); - }); - - it('should extract cause from Reason header if text is not present', () => { - const message: SipCancel = { - request: { - headers: { - Reason: [{ raw: 'SIP ;cause=404' }], - }, - }, - } as any; - - expect(parseInviteRejectionReasons(message)).toEqual(['404']); - }); - - it('should extract text from Reason header when both text and cause are present ', () => { - const message: SipCancel = { - request: { - headers: { Reason: [{ raw: 'SIP ;cause=200 ;text="OK"' }] }, - }, - } as any; - expect(parseInviteRejectionReasons(message)).toEqual(['OK']); - }); - - it('should return the raw reason if no matching text or cause is found', () => { - const message: SipCancel = { - request: { - headers: { - Reason: [{ raw: 'code=486' }], - }, - }, - } as any; - - expect(parseInviteRejectionReasons(message)).toEqual(['code=486']); - }); - - it('should parse multiple reasons and return only the text parts', () => { - const message: SipCancel = { - request: { - headers: { - Reason: [{ raw: 'text="Busy Here"' }, { raw: 'text="Server Internal Error"' }], - }, - }, - } as any; - - expect(parseInviteRejectionReasons(message)).toEqual(['Busy Here', 'Server Internal Error']); - }); - - it('should return an array of parsed reasons when valid reasons are present', () => { - const mockMessage: SipCancel = { - request: { - headers: { - Reason: [{ raw: 'SIP ;cause=200 ;text="Call completed elsewhere"' }, { raw: 'SIP ;cause=486 ;text="Busy Here"' }], - }, - }, - } as any; - - const result = parseInviteRejectionReasons(mockMessage); - expect(result).toEqual(['Call completed elsewhere', 'Busy Here']); - }); - - it('should parse multiple reasons and return the mixed text, cause and raw items, on this order', () => { - const message: SipCancel = { - request: { - headers: { - Reason: [{ raw: 'text="Busy Here"' }, { raw: 'code=503' }, { raw: 'cause=488' }, { raw: 'text="Forbidden"' }], - }, - }, - } as any; - - expect(parseInviteRejectionReasons(message)).toEqual(['Busy Here', 'Forbidden', '488', 'code=503']); - }); - - it('should filter out any undefined or null values from the resulting array', () => { - const message: SipCancel = { - request: { - headers: { - Reason: [ - { raw: 'SIP ;cause=500 ;text="Server Error"' }, - { raw: null as unknown as string }, // Simulate an edge case { raw: '' } - ], - }, - }, - } as any; - expect(parseInviteRejectionReasons(message)).toEqual(['Server Error']); - }); - - it('should handle non-string raw values gracefully and return only valid matches', () => { - const message: SipCancel = { - request: { - headers: { - Reason: [ - { raw: 'text="Service Unavailable"' }, - { raw: { notAString: true } as unknown as string }, // Intentional type misuse for testing - { raw: 'code=486' }, - ], - }, - }, - } as any; - - expect(parseInviteRejectionReasons(message)).toEqual(['Service Unavailable', 'code=486']); - }); - - it('should return an empty array when exceptions are thrown', () => { - // Mock the function to throw an error - const faultyMessage: SipCancel = { - request: { - headers: { - Reason: [ - { - raw: () => { - throw new Error('unexpected error'); - }, - }, - ] as any, - }, - }, - } as any; - expect(parseInviteRejectionReasons(faultyMessage)).toEqual([]); - }); -}); diff --git a/packages/ui-voip/src/lib/parseInviteRejectionReasons.ts b/packages/ui-voip/src/lib/parseInviteRejectionReasons.ts deleted file mode 100644 index 28aadbf626238..0000000000000 --- a/packages/ui-voip/src/lib/parseInviteRejectionReasons.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Cancel as SipCancel } from 'sip.js'; - -export function parseInviteRejectionReasons(message: SipCancel): string[] { - try { - const reasons = message?.request?.headers?.Reason; - const parsedTextReasons: string[] = []; - const parsedCauseReasons: string[] = []; - const rawReasons: string[] = []; - - if (reasons) { - for (const { raw } of reasons) { - if (!raw || typeof raw !== 'string') { - continue; - } - - const textMatch = raw.match(/text="(.+)"/); - if (textMatch?.length && textMatch.length > 1) { - parsedTextReasons.push(textMatch[1]); - continue; - } - const causeMatch = raw.match(/cause=_?(\d+)/); - if (causeMatch?.length && causeMatch.length > 1) { - parsedCauseReasons.push(causeMatch[1]); - continue; - } - - rawReasons.push(raw); - } - } - - return [...parsedTextReasons, ...parsedCauseReasons, ...rawReasons]; - } catch { - return []; - } -} diff --git a/packages/ui-voip/src/providers/VoipProvider.tsx b/packages/ui-voip/src/providers/VoipProvider.tsx deleted file mode 100644 index a7914467d468e..0000000000000 --- a/packages/ui-voip/src/providers/VoipProvider.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { useEffectEvent, useLocalStorage } from '@rocket.chat/fuselage-hooks'; -import type { Device } from '@rocket.chat/ui-contexts'; -import { - useCustomSound, - usePermission, - useSetInputMediaDevice, - useSetOutputMediaDevice, - useSetting, - useToastMessageDispatch, -} from '@rocket.chat/ui-contexts'; -import type { ReactNode } from 'react'; -import { useCallback, useEffect, useMemo } from 'react'; -import { createPortal } from 'react-dom'; -import { useTranslation } from 'react-i18next'; - -import { VoipPopupDraggable } from '../components'; -import VoipPopupPortal from '../components/VoipPopupPortal'; -import type { VoipContextValue } from '../contexts/VoipContext'; -import { VoipContext } from '../contexts/VoipContext'; -import { useVoipClient } from '../hooks/useVoipClient'; - -const VoipProvider = ({ children }: { children: ReactNode }) => { - // Settings - const isVoipSettingEnabled = useSetting('VoIP_TeamCollab_Enabled', false); - const canViewVoipRegistrationInfo = usePermission('view-user-voip-extension'); - const isVoipEnabled = isVoipSettingEnabled && canViewVoipRegistrationInfo; - - const [isLocalRegistered, setStorageRegistered] = useLocalStorage('voip-registered', true); - - // Hooks - const { t } = useTranslation(); - const { voipSounds } = useCustomSound(); - const { voipClient, error } = useVoipClient({ - enabled: isVoipEnabled, - autoRegister: isLocalRegistered, - }); - const setOutputMediaDevice = useSetOutputMediaDevice(); - const setInputMediaDevice = useSetInputMediaDevice(); - const dispatchToastMessage = useToastMessageDispatch(); - - // Refs - const remoteAudioMediaRef = useCallback( - (node: HTMLMediaElement | null) => { - voipClient?.switchAudioElement(node); - }, - [voipClient], - ); - - useEffect(() => { - if (!voipClient) { - return; - } - - const onBeforeUnload = (event: BeforeUnloadEvent) => { - event.preventDefault(); - event.returnValue = true; - }; - - const onCallEstablished = async (): Promise => { - voipSounds.stopDialer(); - voipSounds.stopRinger(); - window.addEventListener('beforeunload', onBeforeUnload); - }; - - const onNetworkDisconnected = (): void => { - if (voipClient.isOngoing()) { - voipClient.endCall(); - } - }; - - const onOutgoingCallRinging = (): void => { - // VoipClient 'outgoingcall' event is emitted when the call is establishing - // and that event is also emitted when the call is accepted - // to avoid disrupting the VoipClient flow, we check if the call is outgoing here. - if (voipClient.isOutgoing()) { - voipSounds.playDialer(); - } - }; - - const onIncomingCallRinging = (): void => { - voipSounds.playRinger(); - }; - - const onCallTerminated = (): void => { - voipSounds.playCallEnded(); - voipSounds.stopDialer(); - voipSounds.stopRinger(); - window.removeEventListener('beforeunload', onBeforeUnload); - }; - - const onRegistrationError = () => { - setStorageRegistered(false); - dispatchToastMessage({ type: 'error', message: t('Voice_calling_registration_failed') }); - }; - - const onIncomingCallError = (reason: string) => { - console.error('incoming call canceled', reason); - if (reason === 'USER_NOT_REGISTERED') { - dispatchToastMessage({ type: 'error', message: t('Incoming_voice_call_canceled_user_not_registered') }); - return; - } - - dispatchToastMessage({ type: 'error', message: t('Incoming_voice_call_canceled_suddenly') }); - }; - - const onRegistered = () => { - setStorageRegistered(true); - }; - - const onUnregister = () => { - setStorageRegistered(false); - }; - - voipClient.on('incomingcall', onIncomingCallRinging); - voipClient.on('outgoingcall', onOutgoingCallRinging); - voipClient.on('callestablished', onCallEstablished); - voipClient.on('callterminated', onCallTerminated); - voipClient.on('registrationerror', onRegistrationError); - voipClient.on('registered', onRegistered); - voipClient.on('unregistered', onUnregister); - voipClient.on('incomingcallerror', onIncomingCallError); - voipClient.networkEmitter.on('disconnected', onNetworkDisconnected); - voipClient.networkEmitter.on('connectionerror', onNetworkDisconnected); - voipClient.networkEmitter.on('localnetworkoffline', onNetworkDisconnected); - - return (): void => { - voipSounds.stopCallEnded(); - voipClient.off('incomingcall', onIncomingCallRinging); - voipClient.off('outgoingcall', onOutgoingCallRinging); - voipClient.off('callestablished', onCallEstablished); - voipClient.off('callterminated', onCallTerminated); - voipClient.off('registrationerror', onRegistrationError); - voipClient.off('registered', onRegistered); - voipClient.off('unregistered', onUnregister); - voipClient.off('incomingcallerror', onIncomingCallError); - voipClient.networkEmitter.off('disconnected', onNetworkDisconnected); - voipClient.networkEmitter.off('connectionerror', onNetworkDisconnected); - voipClient.networkEmitter.off('localnetworkoffline', onNetworkDisconnected); - window.removeEventListener('beforeunload', onBeforeUnload); - }; - }, [dispatchToastMessage, setStorageRegistered, t, voipClient, voipSounds]); - - const changeAudioOutputDevice = useEffectEvent(async (selectedAudioDevice: Device): Promise => { - const element = voipClient?.getAudioElement(); - if (!element) { - console.warn(`Failed to change audio output device: missing audio element reference.`); - return; - } - - setOutputMediaDevice({ outputDevice: selectedAudioDevice, HTMLAudioElement: element }); - }); - - const changeAudioInputDevice = useEffectEvent(async (selectedAudioDevice: Device): Promise => { - if (!voipClient) { - return; - } - - await voipClient.changeAudioInputDevice({ audio: { deviceId: { exact: selectedAudioDevice.id } } }); - setInputMediaDevice(selectedAudioDevice); - }); - - const contextValue = useMemo(() => { - if (!isVoipEnabled) { - return { - isEnabled: false, - voipClient: null, - error: null, - changeAudioInputDevice, - changeAudioOutputDevice, - }; - } - - if (!voipClient || error) { - return { - isEnabled: true, - voipClient: null, - error, - changeAudioInputDevice, - changeAudioOutputDevice, - }; - } - - return { - isEnabled: true, - voipClient, - - changeAudioInputDevice, - changeAudioOutputDevice, - }; - }, [voipClient, isVoipEnabled, error, changeAudioInputDevice, changeAudioOutputDevice]); - - return ( - - {children} - {contextValue.isEnabled && - createPortal( - , - document.body, - )} - - - - - - ); -}; - -export default VoipProvider; diff --git a/packages/ui-voip/src/tests/mocks/index.tsx b/packages/ui-voip/src/tests/mocks/index.tsx deleted file mode 100644 index 36569a4362f3f..0000000000000 --- a/packages/ui-voip/src/tests/mocks/index.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Emitter } from '@rocket.chat/emitter'; -import { MockedModalContext } from '@rocket.chat/mock-providers'; -import type { OperationParams } from '@rocket.chat/rest-typings'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { ReactNode } from 'react'; - -import { VoipContext } from '../../contexts/VoipContext'; -import type { VoipErrorSession, VoipIncomingSession, VoipOngoingSession, VoipOutgoingSession, VoipSession } from '../../definitions'; -import type VoipClient from '../../lib/VoipClient'; - -export const createMockFreeSwitchExtensionDetails = ( - overwrite?: Partial>, -) => ({ - extension: '1000', - context: 'default', - domain: '', - groups: ['default'], - status: 'REGISTERED' as const, - contact: '', - callGroup: 'techsupport', - callerName: 'Extension 1000', - callerNumber: '1000', - userId: '', - name: 'Administrator', - username: 'administrator', - success: true, - ...overwrite, -}); - -export const createMockVoipSession = (partial?: Partial): VoipSession => ({ - type: 'INCOMING', - contact: { name: 'test', id: '1000', host: '' }, - transferedBy: null, - isMuted: false, - isHeld: false, - error: { status: -1, reason: '' }, - accept: jest.fn(), - end: jest.fn(), - mute: jest.fn(), - hold: jest.fn(), - dtmf: jest.fn(), - ...partial, -}); - -export const createMockVoipOngoingSession = (partial?: Partial): VoipOngoingSession => ({ - type: 'ONGOING', - contact: { name: 'test', id: '1000', host: '' }, - transferedBy: null, - isMuted: false, - isHeld: false, - accept: jest.fn(), - end: jest.fn(), - mute: jest.fn(), - hold: jest.fn(), - dtmf: jest.fn(), - ...partial, -}); - -export const createMockVoipErrorSession = (partial?: Partial): VoipErrorSession => ({ - type: 'ERROR', - contact: { name: 'test', id: '1000', host: '' }, - error: { status: -1, reason: '' }, - end: jest.fn(), - ...partial, -}); - -export const createMockVoipOutgoingSession = (partial?: Partial): VoipOutgoingSession => ({ - type: 'OUTGOING', - contact: { name: 'test', id: '1000', host: '' }, - end: jest.fn(), - ...partial, -}); - -export const createMockVoipIncomingSession = (partial?: Partial): VoipIncomingSession => ({ - type: 'INCOMING', - contact: { name: 'test', id: '1000', host: '' }, - transferedBy: null, - end: jest.fn(), - accept: jest.fn(), - ...partial, -}); - -class MockVoipClient extends Emitter { - public _sessionType: VoipSession['type'] = 'INCOMING'; - - setSessionType(type: VoipSession['type']) { - this._sessionType = type; - setTimeout(() => this.emit('stateChanged'), 0); - } - - getSession = () => - ({ - type: this._sessionType, - contact: { id: '1000', host: '', name: 'John Doe' }, - transferedBy: null, - isMuted: false, - isHeld: false, - accept: async () => undefined, - end: async () => undefined, - mute: async (..._: any[]) => undefined, - hold: async (..._: any[]) => undefined, - dtmf: async () => undefined, - error: { status: 488, reason: '' }, - }) as VoipSession; -} - -export function createMockVoipProviders(): [React.FC<{ children: ReactNode }>, InstanceType] { - const voipClient = new MockVoipClient(); - - const contextValue = { - isEnabled: true as const, - voipClient: voipClient as unknown as VoipClient, - error: null, - changeAudioInputDevice: async () => undefined, - changeAudioOutputDevice: async () => undefined, - }; - - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - - const Provider = ({ children }: { children: ReactNode }) => { - return ( - - - {children} - - - ); - }; - - return [Provider, voipClient]; -} diff --git a/packages/ui-voip/src/tests/utils/replaceReactAriaIds.ts b/packages/ui-voip/src/tests/utils/replaceReactAriaIds.ts deleted file mode 100644 index 3963f26e049ad..0000000000000 --- a/packages/ui-voip/src/tests/utils/replaceReactAriaIds.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const replaceReactAriaIds = (container: HTMLElement): HTMLElement => { - const selectors = ['id', 'for', 'aria-labelledby']; - const ariaSelector = (el: string) => `[${el}^="react-aria"]`; - const regexp = /react-aria\d+-\d+/g; - const staticId = 'static-id'; - - const attributesMap: Record = {}; - - container.querySelectorAll(selectors.map(ariaSelector).join(', ')).forEach((el, index) => { - selectors.forEach((selector) => { - const attr = el.getAttribute(selector); - - if (attr?.match(regexp)) { - const newAttr = attributesMap[attr] || `${staticId}-${index}`; - el.setAttribute(selector, newAttr); - attributesMap[attr] = newAttr; - } - }); - }); - - return container; -}; diff --git a/packages/ui-voip/src/utils/setPreciseInterval.ts b/packages/ui-voip/src/utils/setPreciseInterval.ts deleted file mode 100644 index 0f3fb12e3004f..0000000000000 --- a/packages/ui-voip/src/utils/setPreciseInterval.ts +++ /dev/null @@ -1,25 +0,0 @@ -export const setPreciseInterval = (fn: () => void, duration: number) => { - let timeoutId: Parameters[0] = undefined; - const startTime = new Date().getTime(); - - const run = () => { - fn(); - const currentTime = new Date().getTime(); - - let nextTick = duration - (currentTime - startTime); - - if (nextTick < 0) { - nextTick = 0; - } - - timeoutId = setTimeout(() => { - run(); - }, nextTick); - }; - - run(); - - return () => { - timeoutId && clearTimeout(timeoutId); - }; -};