diff --git a/.changeset/gorgeous-houses-sneeze.md b/.changeset/gorgeous-houses-sneeze.md new file mode 100644 index 0000000000000000000000000000000000000000..b2846edb16871a25676591126a2b18df48cf61c6 --- /dev/null +++ b/.changeset/gorgeous-houses-sneeze.md @@ -0,0 +1,14 @@ +--- +'@rocket.chat/freeswitch': major +'@rocket.chat/mock-providers': patch +'@rocket.chat/core-services': patch +'@rocket.chat/model-typings': patch +'@rocket.chat/core-typings': patch +'@rocket.chat/rest-typings': patch +'@rocket.chat/ui-client': patch +'@rocket.chat/ui-voip': patch +'@rocket.chat/i18n': patch +'@rocket.chat/meteor': patch +--- + +Implements integration with FreeSwitch to enable VoIP calls for team collaboration workspaces diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index f8e3d528f16349e2b0127e98aa5cc330ab37c462..0289f1fe5ff560b3c3946773b50d284e3d2f5ea9 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -143,20 +143,6 @@ export async function findPaginatedUsersByStatus({ hasLoggedIn, type, }: FindPaginatedUsersByStatusProps) { - const projection = { - name: 1, - username: 1, - emails: 1, - roles: 1, - status: 1, - active: 1, - avatarETag: 1, - lastLogin: 1, - type: 1, - reason: 1, - federated: 1, - }; - const actualSort: Record<string, 1 | -1> = sort || { username: 1 }; if (sort?.status) { actualSort.active = sort.status; @@ -183,6 +169,22 @@ export async function findPaginatedUsersByStatus({ } const canSeeAllUserInfo = await hasPermissionAsync(uid, 'view-full-other-user-info'); + const canSeeExtension = canSeeAllUserInfo || (await hasPermissionAsync(uid, 'view-user-voip-extension')); + + const projection = { + name: 1, + username: 1, + emails: 1, + roles: 1, + status: 1, + active: 1, + avatarETag: 1, + lastLogin: 1, + type: 1, + reason: 1, + federated: 1, + ...(canSeeExtension ? { freeSwitchExtension: 1 } : {}), + }; match.$or = [ ...(canSeeAllUserInfo ? [{ 'emails.address': { $regex: escapeRegExp(searchTerm || ''), $options: 'i' } }] : []), diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index a640318a9cd01b7d5b26b720708b1fa778e31dd1..5f12d7d7b751aa4ff3e8275dc583fd1551676d83 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -17,7 +17,7 @@ import { Meteor } from 'meteor/meteor'; import { createDirectMessage } from '../../../../server/methods/createDirectMessage'; import { hideRoomMethod } from '../../../../server/methods/hideRoom'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { hasAtLeastOnePermissionAsync, hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { saveRoomSettings } from '../../../channel-settings/server/methods/saveRoomSettings'; import { getRoomByNameOrIdWithOptionToJoin } from '../../../lib/server/functions/getRoomByNameOrIdWithOptionToJoin'; import { settings } from '../../../settings/server'; @@ -327,8 +327,23 @@ API.v1.addRoute( ...(status && { status: { $in: status } }), }; + const canSeeExtension = await hasAtLeastOnePermissionAsync( + this.userId, + ['view-full-other-user-info', 'view-user-voip-extension'], + room._id, + ); + const options = { - projection: { _id: 1, username: 1, name: 1, status: 1, statusText: 1, utcOffset: 1, federated: 1 }, + projection: { + _id: 1, + username: 1, + name: 1, + status: 1, + statusText: 1, + utcOffset: 1, + federated: 1, + ...(canSeeExtension && { freeSwitchExtension: 1 }), + }, skip: offset, limit: count, sort: { diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index 46d40713bad1eebee79625ff4fbe53e995084442..f57943412fb400fcf3b69bc56b1dbcc9c3bcabe6 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -208,6 +208,13 @@ export const permissions = [ // allows to receive a voip call { _id: 'inbound-voip-calls', roles: ['livechat-agent'] }, + // Allow managing team collab voip extensions + { _id: 'manage-voip-extensions', roles: ['admin'] }, + // Allow viewing the extension number of other users + { _id: 'view-user-voip-extension', roles: ['admin', 'user'] }, + // Allow viewing details of an extension + { _id: 'view-voip-extension-details', roles: ['admin', 'user'] }, + { _id: 'remove-livechat-department', roles: ['livechat-manager', 'admin'] }, { _id: 'manage-apps', roles: ['admin'] }, { _id: 'post-readonly', roles: ['admin', 'owner', 'moderator'] }, diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.ts b/apps/meteor/app/lib/server/functions/getFullUserData.ts index 0703b24d9210ff8882815493b2796fbb29255cae..f66f8ecb49c66d5acfecfa7d7d794cc1cdf63277 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.ts +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -35,6 +35,7 @@ const fullFields = { requirePasswordChangeReason: 1, roles: 1, importIds: 1, + freeSwitchExtension: 1, } as const; let publicCustomFields: Record<string, 0 | 1> = {}; @@ -85,6 +86,7 @@ export async function getFullUserDataByIdOrUsernameOrImportId( (searchType === 'username' && searchValue === caller.username) || (searchType === 'importId' && caller.importIds?.includes(searchValue)); const canViewAllInfo = !!myself || (await hasPermissionAsync(userId, 'view-full-other-user-info')); + const canViewExtension = !!myself || (await hasPermissionAsync(userId, 'view-user-voip-extension')); // Only search for importId if the user has permission to view the import id if (searchType === 'importId' && !canViewAllInfo) { @@ -96,6 +98,7 @@ export async function getFullUserDataByIdOrUsernameOrImportId( const options = { projection: { ...fields, + ...(canViewExtension && { freeSwitchExtension: 1 }), ...(myself && { services: 1 }), }, }; diff --git a/apps/meteor/app/models/client/models/Users.ts b/apps/meteor/app/models/client/models/Users.ts index e2d8c7856752f9a1e8d7267b8bdbaa729de52833..3fd1016e528b65000df00e9bcf57adc5890fa98c 100644 --- a/apps/meteor/app/models/client/models/Users.ts +++ b/apps/meteor/app/models/client/models/Users.ts @@ -1,4 +1,5 @@ import type { IRole, IUser } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; import { Mongo } from 'meteor/mongo'; class UsersCollection extends Mongo.Collection<IUser> { @@ -39,4 +40,4 @@ Object.assign(Meteor.users, { }); /** @deprecated new code refer to Minimongo collections like this one; prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ -export const Users = Meteor.users as UsersCollection; +export const Users = Meteor.users as unknown as UsersCollection; diff --git a/apps/meteor/app/utils/client/lib/SDKClient.ts b/apps/meteor/app/utils/client/lib/SDKClient.ts index 3c7e43c85f7c0e1415ef553cde7a54764a323a67..5ca4e112ad6931ca1a2c35e6deccbaa579924501 100644 --- a/apps/meteor/app/utils/client/lib/SDKClient.ts +++ b/apps/meteor/app/utils/client/lib/SDKClient.ts @@ -1,6 +1,7 @@ import type { RestClientInterface } from '@rocket.chat/api-client'; import type { SDK, ClientStream, StreamKeys, StreamNames, StreamerCallbackArgs, ServerMethods } from '@rocket.chat/ddp-client'; import { Emitter } from '@rocket.chat/emitter'; +import { Accounts } from 'meteor/accounts-base'; import { DDPCommon } from 'meteor/ddp-common'; import { Meteor } from 'meteor/meteor'; diff --git a/apps/meteor/client/NavBarV2/NavBar.tsx b/apps/meteor/client/NavBarV2/NavBar.tsx index 908e729c956e0c401c2a881824af4346b5ea10f7..7e61d53e5eff12422ad7fe64c4e33ae7ed4c178d 100644 --- a/apps/meteor/client/NavBarV2/NavBar.tsx +++ b/apps/meteor/client/NavBarV2/NavBar.tsx @@ -1,6 +1,7 @@ import { useToolbar } from '@react-aria/toolbar'; import { NavBar as NavBarComponent, NavBarSection, NavBarGroup, NavBarDivider } from '@rocket.chat/fuselage'; import { usePermission, useTranslation, useUser } from '@rocket.chat/ui-contexts'; +import { useVoipState } from '@rocket.chat/ui-voip'; import React, { useRef } from 'react'; import { useIsCallEnabled, useIsCallReady } from '../contexts/CallContext'; @@ -16,6 +17,7 @@ import { } from './NavBarOmnichannelToolbar'; import { NavBarItemMarketPlaceMenu, NavBarItemAuditMenu, NavBarItemDirectoryPage, NavBarItemHomePage } from './NavBarPagesToolbar'; import { NavBarItemLoginPage, NavBarItemAdministrationMenu, UserMenu } from './NavBarSettingsToolbar'; +import { NavBarItemVoipDialer } from './NavBarVoipToolbar'; const NavBar = () => { const t = useTranslation(); @@ -31,6 +33,7 @@ const NavBar = () => { const showOmnichannelQueueLink = useOmnichannelShowQueueLink(); const isCallEnabled = useIsCallEnabled(); const isCallReady = useIsCallReady(); + const { isEnabled: showVoip } = useVoipState(); const pagesToolbarRef = useRef(null); const { toolbarProps: pagesToolbarProps } = useToolbar({ 'aria-label': t('Pages') }, pagesToolbarRef); @@ -38,6 +41,9 @@ const NavBar = () => { const omnichannelToolbarRef = useRef(null); const { toolbarProps: omnichannelToolbarProps } = useToolbar({ 'aria-label': t('Omnichannel') }, omnichannelToolbarRef); + const voipToolbarRef = useRef(null); + const { toolbarProps: voipToolbarProps } = useToolbar({ 'aria-label': t('Voice_Call') }, voipToolbarRef); + return ( <NavBarComponent aria-label='header'> <NavBarSection> @@ -59,6 +65,14 @@ const NavBar = () => { </NavBarGroup> </> )} + {showVoip && ( + <> + <NavBarDivider /> + <NavBarGroup role='toolbar' ref={voipToolbarRef} {...voipToolbarProps}> + <NavBarItemVoipDialer primary={isCallEnabled} /> + </NavBarGroup> + </> + )} </NavBarSection> <NavBarSection> <NavBarGroup aria-label={t('Workspace_and_user_settings')}> diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx index 85a481f3e2579a3559ffb39c029522bf46fa7a2b..fce9c3d14fd49484efb32f936b9711b5fe61bf33 100644 --- a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx @@ -7,12 +7,14 @@ import React from 'react'; import UserMenuHeader from '../UserMenuHeader'; import { useAccountItems } from './useAccountItems'; import { useStatusItems } from './useStatusItems'; +import { useVoipItems } from './useVoipItems'; export const useUserMenu = (user: IUser) => { const t = useTranslation(); const statusItems = useStatusItems(); const accountItems = useAccountItems(); + const voipItems = useVoipItems(); const logout = useLogout(); const handleLogout = useEffectEvent(() => { @@ -35,6 +37,9 @@ export const useUserMenu = (user: IUser) => { title: t('Status'), items: statusItems, }, + { + items: voipItems, + }, { title: t('Account'), items: accountItems, diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useVoipItems.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useVoipItems.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b3e4cbf22d520f3c76cf62ebbb7fbbd61a246a3f --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useVoipItems.tsx @@ -0,0 +1,67 @@ +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 React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const useVoipItems = (): GenericMenuItemProps[] => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const { clientError, isEnabled, isReady, isRegistered } = useVoipState(); + const { register, unregister } = useVoipAPI(); + + const toggleVoip = useMutation({ + mutationFn: async () => { + if (!isRegistered) { + await register(); + return true; + } + + await unregister(); + return false; + }, + onSuccess: (isEnabled: boolean) => { + dispatchToastMessage({ + type: 'success', + message: isEnabled ? t('Voice_calling_enabled') : t('Voice_calling_disabled'), + }); + }, + }); + + const tooltip = useMemo(() => { + if (clientError) { + return t(clientError.message); + } + + if (!isReady || toggleVoip.isLoading) { + return t('Loading'); + } + + return ''; + }, [clientError, isReady, toggleVoip.isLoading, t]); + + return useMemo(() => { + if (!isEnabled) { + return []; + } + + return [ + { + id: 'toggle-voip', + icon: isRegistered ? 'phone-disabled' : 'phone', + disabled: !isReady || toggleVoip.isLoading, + onClick: () => toggleVoip.mutate(), + content: ( + <Box is='span' title={tooltip}> + {isRegistered ? t('Disable_voice_calling') : t('Enable_voice_calling')} + </Box> + ), + }, + ]; + }, [isEnabled, isRegistered, isReady, tooltip, t, toggleVoip]); +}; + +export default useVoipItems; diff --git a/apps/meteor/client/NavBarV2/NavBarVoipToolbar/NavBarItemVoipDialer.tsx b/apps/meteor/client/NavBarV2/NavBarVoipToolbar/NavBarItemVoipDialer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bdc62c41b1dad49fe549567762aa4510dd9e60d0 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarVoipToolbar/NavBarItemVoipDialer.tsx @@ -0,0 +1,48 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useLayout } from '@rocket.chat/ui-contexts'; +import { useVoipDialer, useVoipState } from '@rocket.chat/ui-voip'; +import type { HTMLAttributes } from 'react'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +type NavBarItemVoipDialerProps = Omit<HTMLAttributes<HTMLElement>, 'is'> & { + primary?: boolean; +}; + +const NavBarItemVoipDialer = (props: NavBarItemVoipDialerProps) => { + const { t } = useTranslation(); + const { sidebar } = useLayout(); + const { clientError, isEnabled, 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 isEnabled ? ( + <NavBarItem + {...props} + title={title} + icon='phone' + onClick={handleToggleDialer} + pressed={isDialerOpen} + disabled={!isReady || !isRegistered} + /> + ) : null; +}; + +export default NavBarItemVoipDialer; diff --git a/apps/meteor/client/NavBarV2/NavBarVoipToolbar/index.ts b/apps/meteor/client/NavBarV2/NavBarVoipToolbar/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f6d317af22987ef489dc57f9bea54bc642a29a9 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarVoipToolbar/index.ts @@ -0,0 +1 @@ +export { default as NavBarItemVoipDialer } from './NavBarItemVoipDialer'; diff --git a/apps/meteor/client/components/UserInfo/UserInfoAction.tsx b/apps/meteor/client/components/UserInfo/UserInfoAction.tsx index 97c64ecbede12672be771ff262cb0df148b9ccca..f58d0fb07482cf6c5d4f6db56afa4ec96ae81bbb 100644 --- a/apps/meteor/client/components/UserInfo/UserInfoAction.tsx +++ b/apps/meteor/client/components/UserInfo/UserInfoAction.tsx @@ -1,4 +1,4 @@ -import { Button } from '@rocket.chat/fuselage'; +import { Button, IconButton } from '@rocket.chat/fuselage'; import type { Keys as IconName } from '@rocket.chat/icons'; import type { ReactElement, ComponentProps } from 'react'; import React from 'react'; @@ -7,10 +7,16 @@ type UserInfoActionProps = { icon: IconName; } & ComponentProps<typeof Button>; -const UserInfoAction = ({ icon, label, ...props }: UserInfoActionProps): ReactElement => ( - <Button icon={icon} title={label} {...props} mi={4}> - {label} - </Button> -); +const UserInfoAction = ({ icon, label, title, ...props }: UserInfoActionProps): ReactElement => { + if (!label && icon && title) { + return <IconButton small secondary icon={icon} title={title} aria-label={title} {...props} mi={4} size={40} />; + } + + return ( + <Button icon={icon} {...props} mi={4}> + {label} + </Button> + ); +}; export default UserInfoAction; diff --git a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/index.ts b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d01e5a6a5dffd7e1fd5403849c9248a93c69d6c9 --- /dev/null +++ b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/index.ts @@ -0,0 +1 @@ +export * from './useStartCallRoomAction'; diff --git a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useStartCallRoomAction.tsx b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useStartCallRoomAction.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ee3117d664d1f5116903490c4fde99158f207322 --- /dev/null +++ b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useStartCallRoomAction.tsx @@ -0,0 +1,41 @@ +import { GenericMenu } from '@rocket.chat/ui-client'; +import React, { useMemo } from 'react'; + +import HeaderToolbarAction from '../../../components/Header/HeaderToolbarAction'; +import type { RoomToolboxActionConfig } from '../../../views/room/contexts/RoomToolboxContext'; +import useVideoConfMenuOptions from './useVideoConfMenuOptions'; +import useVoipMenuOptions from './useVoipMenuOptions'; + +export const useStartCallRoomAction = () => { + const voipCall = useVideoConfMenuOptions(); + const videoCall = useVoipMenuOptions(); + + return useMemo((): RoomToolboxActionConfig | undefined => { + if (!videoCall.allowed && !voipCall.allowed) { + return undefined; + } + + return { + id: 'start-call', + title: 'Call', + icon: 'phone', + groups: [...videoCall.groups, ...voipCall.groups], + disabled: videoCall.disabled && voipCall.disabled, + full: true, + order: Math.max(voipCall.order, videoCall.order), + featured: true, + renderToolboxItem: ({ id, icon, title, disabled, className }) => ( + <GenericMenu + button={<HeaderToolbarAction />} + key={id} + title={title} + disabled={disabled} + items={[...voipCall.items, ...videoCall.items]} + className={className} + placement='bottom-start' + icon={icon} + /> + ), + }; + }, [videoCall, voipCall]); +}; diff --git a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction.ts b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useVideoConfMenuOptions.tsx similarity index 53% rename from apps/meteor/client/hooks/roomActions/useStartCallRoomAction.ts rename to apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useVideoConfMenuOptions.tsx index 8d1fa251c051ddce70980083f3d357801d913962..13b92e7f44a552ca0ee8a1e5b07d2b18d242d22e 100644 --- a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useVideoConfMenuOptions.tsx @@ -1,17 +1,21 @@ -import { isRoomFederated } from '@rocket.chat/core-typings'; -import { useStableArray, useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useSetting, useUser, usePermission } from '@rocket.chat/ui-contexts'; -import { useMemo } from 'react'; +import { isOmnichannelRoom, isRoomFederated } from '@rocket.chat/core-typings'; +import { Box } from '@rocket.chat/fuselage'; +import { useEffectEvent, useStableArray } from '@rocket.chat/fuselage-hooks'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { usePermission, useSetting, useUser } from '@rocket.chat/ui-contexts'; +import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useVideoConfDispatchOutgoing, useVideoConfIsCalling, useVideoConfIsRinging } from '../../contexts/VideoConfContext'; -import { VideoConfManager } from '../../lib/VideoConfManager'; -import { useRoom } from '../../views/room/contexts/RoomContext'; -import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; -import { useVideoConfWarning } from '../../views/room/contextualBar/VideoConference/hooks/useVideoConfWarning'; +import { useVideoConfDispatchOutgoing, useVideoConfIsCalling, useVideoConfIsRinging } from '../../../contexts/VideoConfContext'; +import { VideoConfManager } from '../../../lib/VideoConfManager'; +import { useRoom } from '../../../views/room/contexts/RoomContext'; +import type { RoomToolboxActionConfig } from '../../../views/room/contexts/RoomToolboxContext'; +import { useVideoConfWarning } from '../../../views/room/contextualBar/VideoConference/hooks/useVideoConfWarning'; -export const useStartCallRoomAction = () => { +const useVideoConfMenuOptions = () => { + const { t } = useTranslation(); const room = useRoom(); + const user = useUser(); const federated = isRoomFederated(room); const ownUser = room.uids?.length === 1 ?? false; @@ -24,8 +28,6 @@ export const useStartCallRoomAction = () => { const isCalling = useVideoConfIsCalling(); const isRinging = useVideoConfIsRinging(); - const { t } = useTranslation(); - const enabledForDMs = useSetting('VideoConf_Enable_DMs', true); const enabledForChannels = useSetting('VideoConf_Enable_Channels', true); const enabledForTeams = useSetting('VideoConf_Enable_Teams', true); @@ -43,13 +45,13 @@ export const useStartCallRoomAction = () => { ].filter((group): group is RoomToolboxActionConfig['groups'][number] => !!group), ); - const enabled = groups.length > 0; - - const user = useUser(); - - const allowed = enabled && permittedToCallManagement && (!user?.username || !room.muted?.includes(user.username)) && !ownUser; + const visible = groups.length > 0; + const allowed = visible && permittedToCallManagement && (!user?.username || !room.muted?.includes(user.username)) && !ownUser; + const disabled = federated || (!!room.ro && !permittedToPostReadonly); + const tooltip = disabled ? t('core.Video_Call_unavailable_for_this_type_of_room') : ''; + const order = isOmnichannelRoom(room) ? -1 : 4; - const handleOpenVideoConf = useMutableCallback(async () => { + const handleOpenVideoConf = useEffectEvent(async () => { if (isCalling || isRinging) { return; } @@ -62,26 +64,29 @@ export const useStartCallRoomAction = () => { } }); - const disabled = federated || (!!room.ro && !permittedToPostReadonly); - - return useMemo((): RoomToolboxActionConfig | undefined => { - if (!allowed) { - return undefined; - } + return useMemo(() => { + const items: GenericMenuItemProps[] = [ + { + id: 'start-video-call', + icon: 'video', + disabled, + onClick: handleOpenVideoConf, + content: ( + <Box is='span' title={tooltip}> + {t('Video_call')} + </Box> + ), + }, + ]; return { - id: 'start-call', + items, + disabled, + allowed, + order, groups, - title: 'Call', - icon: 'phone', - action: () => void handleOpenVideoConf(), - ...(disabled && { - tooltip: t('core.Video_Call_unavailable_for_this_type_of_room'), - disabled: true, - }), - full: true, - order: 4, - featured: true, }; - }, [allowed, disabled, groups, handleOpenVideoConf, t]); + }, [allowed, disabled, groups, handleOpenVideoConf, order, t, tooltip]); }; + +export default useVideoConfMenuOptions; diff --git a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useVoipMenuOptions.tsx b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useVoipMenuOptions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ca62f372f4ca65ac89ba3854b2fc267e7293552c --- /dev/null +++ b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useVoipMenuOptions.tsx @@ -0,0 +1,69 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { useUserId } from '@rocket.chat/ui-contexts'; +import { useVoipAPI, useVoipState } from '@rocket.chat/ui-voip'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useMediaPermissions } from '../../../views/room/composer/messageBox/hooks/useMediaPermissions'; +import { useRoom } from '../../../views/room/contexts/RoomContext'; +import { useUserInfoQuery } from '../../useUserInfoQuery'; + +const useVoipMenuOptions = () => { + const { t } = useTranslation(); + const { uids = [] } = useRoom(); + const ownUserId = useUserId(); + + const [isMicPermissionDenied] = useMediaPermissions('microphone'); + + 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 } = {}, isLoading } = useUserInfoQuery({ userId: remoteUserId }, { enabled: Boolean(remoteUserId) }); + + const isRemoteRegistered = !!remoteUser?.freeSwitchExtension; + const isDM = members.length === 1; + + const disabled = isMicPermissionDenied || !isDM || !isRemoteRegistered || !isRegistered || isInCall || isLoading; + + const title = useMemo(() => { + if (isMicPermissionDenied) { + return t('Microphone_access_not_allowed'); + } + + if (isInCall) { + return t('Unable_to_make_calls_while_another_is_ongoing'); + } + + return disabled ? t('Voice_calling_disabled') : ''; + }, [disabled, isInCall, isMicPermissionDenied, t]); + + return useMemo(() => { + const items: GenericMenuItemProps[] = [ + { + id: 'start-voip-call', + icon: 'phone', + disabled, + onClick: () => makeCall(remoteUser?.freeSwitchExtension as string), + content: ( + <Box is='span' title={title}> + {t('Voice_call')} + </Box> + ), + }, + ]; + + return { + items: isEnabled ? items : [], + groups: ['direct'] as const, + disabled, + allowed: isEnabled, + order: 4, + }; + }, [disabled, title, t, isEnabled, makeCall, remoteUser?.freeSwitchExtension]); +}; + +export default useVoipMenuOptions; diff --git a/apps/meteor/client/hooks/useUserInfoQuery.ts b/apps/meteor/client/hooks/useUserInfoQuery.ts index 4fac5212b8037284d9819d55212895ee8e3df7f7..fdbe793d60e329e25b9f51ef36d0c72cfde690bc 100644 --- a/apps/meteor/client/hooks/useUserInfoQuery.ts +++ b/apps/meteor/client/hooks/useUserInfoQuery.ts @@ -1,13 +1,14 @@ import type { UsersInfoParamsGet } from '@rocket.chat/rest-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; -// a hook using tanstack useQuery and useEndpoint that fetches user information from the `users.info` endpoint -export const useUserInfoQuery = (params: UsersInfoParamsGet) => { - const getUserInfo = useEndpoint('GET', '/v1/users.info'); - const result = useQuery(['users.info', params], () => getUserInfo({ ...params }), { - keepPreviousData: true, - }); +type UserInfoQueryOptions = { + enabled?: boolean; + keepPreviousData?: boolean; +}; - return result; +// a hook using tanstack useQuery and useEndpoint that fetches user information from the `users.info` endpoint +export const useUserInfoQuery = (params: UsersInfoParamsGet, options: UserInfoQueryOptions = { keepPreviousData: true }) => { + const getUserInfo = useEndpoint('GET', '/v1/users.info'); + return useQuery(['users.info', params], () => getUserInfo({ ...params }), options); }; diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index 4817cee83317b3a48331f3497cb1afba66258464..ad5df9503833e4741467330df2da4ace1f52f6a0 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -1,3 +1,4 @@ +import { VoipProvider } from '@rocket.chat/ui-voip'; import type { ReactNode } from 'react'; import React from 'react'; @@ -6,7 +7,7 @@ import ActionManagerProvider from './ActionManagerProvider'; import AuthenticationProvider from './AuthenticationProvider/AuthenticationProvider'; import AuthorizationProvider from './AuthorizationProvider'; import AvatarUrlProvider from './AvatarUrlProvider'; -import { CallProvider } from './CallProvider'; +import { CallProvider as OmnichannelCallProvider } from './CallProvider'; import ConnectionStatusProvider from './ConnectionStatusProvider'; import CustomSoundProvider from './CustomSoundProvider'; import { DeviceProvider } from './DeviceProvider/DeviceProvider'; @@ -51,9 +52,11 @@ const MeteorProvider = ({ children }: MeteorProviderProps) => ( <UserPresenceProvider> <ActionManagerProvider> <VideoConfProvider> - <CallProvider> - <OmnichannelProvider>{children}</OmnichannelProvider> - </CallProvider> + <VoipProvider> + <OmnichannelCallProvider> + <OmnichannelProvider>{children}</OmnichannelProvider> + </OmnichannelCallProvider> + </VoipProvider> </VideoConfProvider> </ActionManagerProvider> </UserPresenceProvider> diff --git a/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx b/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx index c0c6f94a4ed88e9a0529751eb45744dcfa126ffa..e9ad8cc73836fa24acc989769a07d24f7c83bd36 100644 --- a/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx +++ b/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx @@ -7,12 +7,14 @@ import React from 'react'; import UserMenuHeader from '../UserMenuHeader'; import { useAccountItems } from './useAccountItems'; import { useStatusItems } from './useStatusItems'; +import useVoipItems from './useVoipItems'; export const useUserMenu = (user: IUser) => { const t = useTranslation(); const statusItems = useStatusItems(); const accountItems = useAccountItems(); + const voipItems = useVoipItems(); const logout = useLogout(); const handleLogout = useMutableCallback(() => { @@ -35,6 +37,9 @@ export const useUserMenu = (user: IUser) => { title: t('Status'), items: statusItems, }, + { + items: voipItems, + }, { title: t('Account'), items: accountItems, diff --git a/apps/meteor/client/sidebar/header/hooks/useVoipItems.tsx b/apps/meteor/client/sidebar/header/hooks/useVoipItems.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d7cbf2428c323aff4d7546499dd968c4fccdc79b --- /dev/null +++ b/apps/meteor/client/sidebar/header/hooks/useVoipItems.tsx @@ -0,0 +1,67 @@ +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 React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +const useVoipItems = (): GenericMenuItemProps[] => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const { clientError, isEnabled, isReady, isRegistered } = useVoipState(); + const { register, unregister } = useVoipAPI(); + + const toggleVoip = useMutation({ + mutationFn: async () => { + if (!isRegistered) { + await register(); + return true; + } + + await unregister(); + return false; + }, + onSuccess: (isEnabled: boolean) => { + dispatchToastMessage({ + type: 'success', + message: isEnabled ? t('Voice_calling_enabled') : t('Voice_calling_disabled'), + }); + }, + }); + + const tooltip = useMemo(() => { + if (clientError) { + return t(clientError.message); + } + + if (!isReady || toggleVoip.isLoading) { + return t('Loading'); + } + + return ''; + }, [clientError, isReady, toggleVoip.isLoading, t]); + + return useMemo(() => { + if (!isEnabled) { + return []; + } + + return [ + { + id: 'toggle-voip', + icon: isRegistered ? 'phone-disabled' : 'phone', + disabled: !isReady || toggleVoip.isLoading, + onClick: () => toggleVoip.mutate(), + content: ( + <Box is='span' title={tooltip}> + {isRegistered ? t('Disable_voice_calling') : t('Enable_voice_calling')} + </Box> + ), + }, + ]; + }, [isEnabled, isRegistered, isReady, tooltip, t, toggleVoip]); +}; + +export default useVoipItems; diff --git a/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx b/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx index 800222282054569a0b0c889e7fbe93d991d8f2dc..31ff9a96f84235c64fd1f4855007647d69057cb5 100644 --- a/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx @@ -6,7 +6,7 @@ import React, { useCallback, useMemo } from 'react'; import { UserInfoAction } from '../../../components/UserInfo'; import { useActionSpread } from '../../hooks/useActionSpread'; -import type { AdminUserTab } from './AdminUsersPage'; +import type { AdminUsersTab } from './AdminUsersPage'; import { useChangeAdminStatusAction } from './hooks/useChangeAdminStatusAction'; import { useChangeUserStatusAction } from './hooks/useChangeUserStatusAction'; import { useDeleteUserAction } from './hooks/useDeleteUserAction'; @@ -19,7 +19,7 @@ type AdminUserInfoActionsProps = { isFederatedUser: IUser['federated']; isActive: boolean; isAdmin: boolean; - tab: AdminUserTab; + tab: AdminUsersTab; onChange: () => void; onReload: () => void; }; diff --git a/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx b/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx index 2318e5ae1dc10a79291977ef16cf09a5f6333e56..59d91ce5ada60304c88051aca4f2810ff11c209c 100644 --- a/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx @@ -14,12 +14,12 @@ import { UserInfo } from '../../../components/UserInfo'; import { UserStatus } from '../../../components/UserStatus'; import { getUserEmailVerified } from '../../../lib/utils/getUserEmailVerified'; import AdminUserInfoActions from './AdminUserInfoActions'; -import type { AdminUserTab } from './AdminUsersPage'; +import type { AdminUsersTab } from './AdminUsersPage'; type AdminUserInfoWithDataProps = { uid: IUser['_id']; onReload: () => void; - tab: AdminUserTab; + tab: AdminUsersTab; }; const AdminUserInfoWithData = ({ uid, onReload, tab }: AdminUserInfoWithDataProps): ReactElement => { diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index 56641f8959d0fa1570a45bcb942fae3c7f919ef0..78950c0fe22a978a79481c029de2d41f17ef4435 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -39,9 +39,9 @@ export type UsersFilters = { roles: OptionProp[]; }; -export type AdminUserTab = 'all' | 'active' | 'deactivated' | 'pending'; +export type AdminUsersTab = 'all' | 'active' | 'deactivated' | 'pending'; -export type UsersTableSortingOptions = 'name' | 'username' | 'emails.address' | 'status' | 'active'; +export type UsersTableSortingOption = 'name' | 'username' | 'emails.address' | 'status' | 'active' | 'freeSwitchExtension'; const AdminUsersPage = (): ReactElement => { const t = useTranslation(); @@ -65,9 +65,9 @@ const AdminUsersPage = (): ReactElement => { const { data, error } = useQuery(['roles'], async () => getRoles()); const paginationData = usePagination(); - const sortData = useSort<UsersTableSortingOptions>('name'); + const sortData = useSort<UsersTableSortingOption>('name'); - const [tab, setTab] = useState<AdminUserTab>('all'); + const [tab, setTab] = useState<AdminUsersTab>('all'); const [userFilters, setUserFilters] = useState<UsersFilters>({ text: '', roles: [] }); const searchTerm = useDebouncedValue(userFilters.text, 500); @@ -89,7 +89,7 @@ const AdminUsersPage = (): ReactElement => { filteredUsersQueryResult?.refetch(); }; - const handleTabChange = (tab: AdminUserTab) => { + const handleTabChange = (tab: AdminUsersTab) => { setTab(tab); paginationData.setCurrent(0); diff --git a/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.spec.tsx b/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b11ca46ce40d4f9deffcf416ca99cd48af17fde5 --- /dev/null +++ b/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.spec.tsx @@ -0,0 +1,24 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import '@testing-library/jest-dom'; + +import UserPageHeaderContent from './UserPageHeaderContentWithSeatsCap'; + +it('should render "Associate Extension" button when VoIP_TeamCollab_Enabled setting is enabled', async () => { + render(<UserPageHeaderContent activeUsers={1} maxActiveUsers={1} isSeatsCapExceeded={false} />, { + legacyRoot: true, + wrapper: mockAppRoot().withJohnDoe().withSetting('VoIP_TeamCollab_Enabled', true).build(), + }); + + expect(screen.getByRole('button', { name: 'Assign_extension' })).toBeEnabled(); +}); + +it('should not render "Associate Extension" button when VoIP_TeamCollab_Enabled setting is disabled', async () => { + render(<UserPageHeaderContent activeUsers={1} maxActiveUsers={1} isSeatsCapExceeded={false} />, { + legacyRoot: true, + wrapper: mockAppRoot().withJohnDoe().withSetting('VoIP_TeamCollab_Enabled', false).build(), + }); + + expect(screen.queryByRole('button', { name: 'Assign_extension' })).not.toBeInTheDocument(); +}); diff --git a/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx b/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx index 3000b4f51a5aee0c14ca1cb1d7c1b9e7280f1991..a023f5229816a536ad5244a20df1e57f9fb3ec21 100644 --- a/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx +++ b/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx @@ -1,11 +1,12 @@ import { Button, ButtonGroup, Margins } from '@rocket.chat/fuselage'; -import { useTranslation, useRouter } from '@rocket.chat/ui-contexts'; +import { useSetModal, useTranslation, useRouter, useSetting } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; import { useExternalLink } from '../../../hooks/useExternalLink'; import { useCheckoutUrl } from '../subscription/hooks/useCheckoutUrl'; import SeatsCapUsage from './SeatsCapUsage'; +import AssignExtensionModal from './voip/AssignExtensionModal'; type UserPageHeaderContentWithSeatsCapProps = { activeUsers: number; @@ -20,6 +21,9 @@ const UserPageHeaderContentWithSeatsCap = ({ }: UserPageHeaderContentWithSeatsCapProps): ReactElement => { const t = useTranslation(); const router = useRouter(); + const setModal = useSetModal(); + + const canRegisterExtension = useSetting('VoIP_TeamCollab_Enabled'); const manageSubscriptionUrl = useCheckoutUrl()({ target: 'user-page', action: 'buy_more' }); const openExternalLink = useExternalLink(); @@ -38,6 +42,11 @@ const UserPageHeaderContentWithSeatsCap = ({ <SeatsCapUsage members={activeUsers} limit={maxActiveUsers} /> </Margins> <ButtonGroup> + {canRegisterExtension && ( + <Button icon='phone' onClick={(): void => setModal(<AssignExtensionModal onClose={(): void => setModal(null)} />)}> + {t('Assign_extension')} + </Button> + )} <Button icon='mail' onClick={handleInviteButtonClick} disabled={isSeatsCapExceeded}> {t('Invite')} </Button> diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.spec.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a1a572ad23a6cf0a52a5c7b1bdfb7d448ed7e94b --- /dev/null +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.spec.tsx @@ -0,0 +1,98 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { createFakeUser } from '../../../../../tests/mocks/data'; +import UsersTable from './UsersTable'; + +const createFakeAdminUser = (freeSwitchExtension?: string) => + createFakeUser({ + active: true, + roles: ['admin'], + type: 'user', + freeSwitchExtension, + }); + +it('should not render "Voice call extension" column when voice call is disabled', async () => { + const user = createFakeAdminUser('1000'); + + render( + <UsersTable + filteredUsersQueryResult={{ isSuccess: true, data: { users: [user], count: 1, offset: 1, total: 1 } } as any} + setUserFilters={() => undefined} + tab='all' + onReload={() => undefined} + paginationData={{} as any} + sortData={{} as any} + isSeatsCapExceeded={false} + roleData={undefined} + />, + { + legacyRoot: true, + wrapper: mockAppRoot().withUser(user).withSetting('VoIP_TeamCollab_Enabled', false).build(), + }, + ); + + expect(screen.queryByText('Voice_call_extension')).not.toBeInTheDocument(); + + screen.getByRole('button', { name: 'More_actions' }).click(); + expect(await screen.findByRole('listbox')).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Assign_extension/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Unassign_extension/ })).not.toBeInTheDocument(); +}); + +it('should render "Unassign_extension" button when user has a associated extension', async () => { + const user = createFakeAdminUser('1000'); + + render( + <UsersTable + filteredUsersQueryResult={{ isSuccess: true, data: { users: [user], count: 1, offset: 1, total: 1 } } as any} + setUserFilters={() => undefined} + tab='all' + onReload={() => undefined} + paginationData={{} as any} + sortData={{} as any} + isSeatsCapExceeded={false} + roleData={undefined} + />, + { + legacyRoot: true, + wrapper: mockAppRoot().withUser(user).withSetting('VoIP_TeamCollab_Enabled', true).build(), + }, + ); + + expect(screen.getByText('Voice_call_extension')).toBeInTheDocument(); + + screen.getByRole('button', { name: 'More_actions' }).click(); + expect(await screen.findByRole('listbox')).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Assign_extension/ })).not.toBeInTheDocument(); + expect(screen.getByRole('option', { name: /Unassign_extension/ })).toBeInTheDocument(); +}); + +it('should render "Assign_extension" button when user has no associated extension', async () => { + const user = createFakeAdminUser(); + + render( + <UsersTable + filteredUsersQueryResult={{ isSuccess: true, data: { users: [user], count: 1, offset: 1, total: 1 } } as any} + setUserFilters={() => undefined} + tab='all' + onReload={() => undefined} + paginationData={{} as any} + sortData={{} as any} + isSeatsCapExceeded={false} + roleData={undefined} + />, + { + legacyRoot: true, + wrapper: mockAppRoot().withUser(user).withSetting('VoIP_TeamCollab_Enabled', true).build(), + }, + ); + + expect(screen.getByText('Voice_call_extension')).toBeInTheDocument(); + + screen.getByRole('button', { name: 'More_actions' }).click(); + expect(await screen.findByRole('listbox')).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /Assign_extension/ })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Unassign_extension/ })).not.toBeInTheDocument(); +}); diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index abdf8cb787c35f309551c9c0526417aa2f4cfdb5..531669ca584a52ee66e24a5f19dc551fff3fbaa4 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -3,7 +3,7 @@ import { Pagination } from '@rocket.chat/fuselage'; import { useEffectEvent, useBreakpoints } from '@rocket.chat/fuselage-hooks'; import type { PaginatedResult, DefaultUserInfo } from '@rocket.chat/rest-typings'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; +import { useRouter, useTranslation, useSetting } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import type { ReactElement, Dispatch, SetStateAction } from 'react'; import React, { useMemo } from 'react'; @@ -18,18 +18,18 @@ import { } from '../../../../components/GenericTable'; import type { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; import type { useSort } from '../../../../components/GenericTable/hooks/useSort'; -import type { AdminUserTab, UsersFilters, UsersTableSortingOptions } from '../AdminUsersPage'; +import type { AdminUsersTab, UsersFilters, UsersTableSortingOption } from '../AdminUsersPage'; import UsersTableFilters from './UsersTableFilters'; import UsersTableRow from './UsersTableRow'; type UsersTableProps = { - tab: AdminUserTab; + tab: AdminUsersTab; roleData: { roles: IRole[] } | undefined; onReload: () => void; setUserFilters: Dispatch<SetStateAction<UsersFilters>>; filteredUsersQueryResult: UseQueryResult<PaginatedResult<{ users: Serialized<DefaultUserInfo>[] }>>; paginationData: ReturnType<typeof usePagination>; - sortData: ReturnType<typeof useSort<UsersTableSortingOptions>>; + sortData: ReturnType<typeof useSort<UsersTableSortingOption>>; isSeatsCapExceeded: boolean; }; @@ -49,6 +49,7 @@ const UsersTable = ({ const isMobile = !breakpoints.includes('xl'); const isLaptop = !breakpoints.includes('xxl'); + const isVoIPEnabled = useSetting<boolean>('VoIP_TeamCollab_Enabled') || false; const { data, isLoading, isError, isSuccess } = filteredUsersQueryResult; @@ -111,9 +112,21 @@ const UsersTable = ({ {t('Pending_action')} </GenericTableHeaderCell> ), - <GenericTableHeaderCell key='actions' w={tab === 'pending' ? 'x204' : ''} />, + tab === 'all' && isVoIPEnabled && ( + <GenericTableHeaderCell + w='x180' + key='freeSwitchExtension' + direction={sortDirection} + active={sortBy === 'freeSwitchExtension'} + onClick={setSort} + sort='freeSwitchExtension' + > + {t('Voice_call_extension')} + </GenericTableHeaderCell> + ), + <GenericTableHeaderCell key='actions' w={tab === 'pending' ? 'x204' : 'x50'} />, ], - [isLaptop, isMobile, setSort, sortBy, sortDirection, t, tab], + [isLaptop, isMobile, setSort, sortBy, sortDirection, t, tab, isVoIPEnabled], ); return ( @@ -156,6 +169,7 @@ const UsersTable = ({ onReload={onReload} tab={tab} isSeatsCapExceeded={isSeatsCapExceeded} + showVoipExtension={isVoIPEnabled} /> ))} </GenericTableBody> diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index c8fa1eae7704275afad59c84dfb1a1cd38044eae..8dc5b4472e1bbc6977530808416886e26386b79f 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -7,16 +7,17 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Roles } from '../../../../../app/models/client'; +import { Roles } from '../../../../../app/models/client/models/Roles'; import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable'; import { UserStatus } from '../../../../components/UserStatus'; -import type { AdminUserTab } from '../AdminUsersPage'; +import type { AdminUsersTab } from '../AdminUsersPage'; import { useChangeAdminStatusAction } from '../hooks/useChangeAdminStatusAction'; import { useChangeUserStatusAction } from '../hooks/useChangeUserStatusAction'; import { useDeleteUserAction } from '../hooks/useDeleteUserAction'; import { useResetE2EEKeyAction } from '../hooks/useResetE2EEKeyAction'; import { useResetTOTPAction } from '../hooks/useResetTOTPAction'; import { useSendWelcomeEmailMutation } from '../hooks/useSendWelcomeEmailMutation'; +import { useVoipExtensionAction } from '../hooks/useVoipExtensionAction'; type UsersTableRowProps = { user: Serialized<DefaultUserInfo>; @@ -24,14 +25,24 @@ type UsersTableRowProps = { isMobile: boolean; isLaptop: boolean; onReload: () => void; - tab: AdminUserTab; + tab: AdminUsersTab; isSeatsCapExceeded: boolean; + showVoipExtension: boolean; }; -const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSeatsCapExceeded }: UsersTableRowProps): ReactElement => { +const UsersTableRow = ({ + user, + onClick, + onReload, + isMobile, + isLaptop, + tab, + isSeatsCapExceeded, + showVoipExtension, +}: UsersTableRowProps): ReactElement => { const { t } = useTranslation(); - const { _id, emails, username, name, roles, status, active, avatarETag, lastLogin, type } = user; + const { _id, emails, username = '', name = '', roles, status, active, avatarETag, lastLogin, type, freeSwitchExtension } = user; const registrationStatusText = useMemo(() => { const usersExcludedFromPending = ['bot', 'app']; @@ -64,10 +75,17 @@ const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSea const resetTOTPAction = useResetTOTPAction(userId); const resetE2EKeyAction = useResetE2EEKeyAction(userId); const resendWelcomeEmail = useSendWelcomeEmailMutation(); + const voipExtensionAction = useVoipExtensionAction({ extension: freeSwitchExtension, username, name }); const isNotPendingDeactivatedNorFederated = tab !== 'pending' && tab !== 'deactivated' && !isFederatedUser; const menuOptions = useMemo( () => ({ + ...(voipExtensionAction && { + voipExtensionAction: { + label: { label: voipExtensionAction.label, icon: voipExtensionAction.icon }, + action: voipExtensionAction.action, + }, + }), ...(isNotPendingDeactivatedNorFederated && changeAdminStatusAction && { makeAdmin: { @@ -102,6 +120,7 @@ const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSea isNotPendingDeactivatedNorFederated, resetE2EKeyAction, resetTOTPAction, + voipExtensionAction, ], ); @@ -154,6 +173,12 @@ const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSea </GenericTableCell> )} + {tab === 'all' && showVoipExtension && username && ( + <GenericTableCell fontScale='p2' color='hint' withTruncatedText> + {freeSwitchExtension || t('Not_assigned')} + </GenericTableCell> + )} + <GenericTableCell onClick={(e): void => { e.stopPropagation(); @@ -179,6 +204,8 @@ const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSea placement='bottom-start' flexShrink={0} key='menu' + aria-label={t('More_actions')} + title={t('More_actions')} renderItem={({ label: { label, icon }, ...props }): ReactElement => ( <Option label={label} title={label} icon={icon} variant={label === 'Delete' ? 'danger' : ''} {...props} /> )} diff --git a/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts b/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts index f6d14a548b46f5e4eb918e0925682574da72a6a0..788690e840228e2ba2f9ec1e33ff7cc3192dc312 100644 --- a/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts +++ b/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts @@ -6,14 +6,14 @@ import { useMemo } from 'react'; import type { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; import type { useSort } from '../../../../components/GenericTable/hooks/useSort'; -import type { AdminUserTab, UsersTableSortingOptions } from '../AdminUsersPage'; +import type { AdminUsersTab, UsersTableSortingOption } from '../AdminUsersPage'; type UseFilteredUsersOptions = { searchTerm: string; prevSearchTerm: MutableRefObject<string>; - tab: AdminUserTab; + tab: AdminUsersTab; paginationData: ReturnType<typeof usePagination>; - sortData: ReturnType<typeof useSort<UsersTableSortingOptions>>; + sortData: ReturnType<typeof useSort<UsersTableSortingOption>>; selectedRoles: string[]; }; @@ -26,7 +26,7 @@ const useFilteredUsers = ({ searchTerm, prevSearchTerm, sortData, paginationData setCurrent(0); } - const listUsersPayload: Partial<Record<AdminUserTab, UsersListStatusParamsGET>> = { + const listUsersPayload: Partial<Record<AdminUsersTab, UsersListStatusParamsGET>> = { all: {}, pending: { hasLoggedIn: false, diff --git a/apps/meteor/client/views/admin/users/hooks/useVoipExtensionAction.tsx b/apps/meteor/client/views/admin/users/hooks/useVoipExtensionAction.tsx new file mode 100644 index 0000000000000000000000000000000000000000..23ab468b6c4bd78c779460f4e6a1ebc1c7aedd67 --- /dev/null +++ b/apps/meteor/client/views/admin/users/hooks/useVoipExtensionAction.tsx @@ -0,0 +1,37 @@ +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useSetModal, useSetting } from '@rocket.chat/ui-contexts'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { Action } from '../../../hooks/useActionSpread'; +import AssignExtensionModal from '../voip/AssignExtensionModal'; +import RemoveExtensionModal from '../voip/RemoveExtensionModal'; + +type VoipExtensionActionParams = { + name: string; + username: string; + extension?: string; +}; + +export const useVoipExtensionAction = ({ name, username, extension }: VoipExtensionActionParams): Action | undefined => { + const isVoipEnabled = useSetting('VoIP_TeamCollab_Enabled'); + const { t } = useTranslation(); + const setModal = useSetModal(); + + const handleExtensionAssignment = useEffectEvent(() => { + if (extension) { + setModal(<RemoveExtensionModal name={name} username={username} extension={extension} onClose={(): void => setModal(null)} />); + return; + } + + setModal(<AssignExtensionModal defaultUsername={username} onClose={(): void => setModal(null)} />); + }); + + return isVoipEnabled + ? { + icon: extension ? 'phone-disabled' : 'phone', + label: extension ? t('Unassign_extension') : t('Assign_extension'), + action: handleExtensionAssignment, + } + : undefined; +}; diff --git a/apps/meteor/client/views/admin/users/voip/AssignExtensionButton.tsx b/apps/meteor/client/views/admin/users/voip/AssignExtensionButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..06b0514d2a0132630bd404d1c54436853398f2c6 --- /dev/null +++ b/apps/meteor/client/views/admin/users/voip/AssignExtensionButton.tsx @@ -0,0 +1,26 @@ +import { IconButton } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useSetModal } from '@rocket.chat/ui-contexts'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { GenericTableCell } from '../../../../components/GenericTable'; +import AssignExtensionModal from './AssignExtensionModal'; + +const AssignExtensionButton = ({ username }: { username: string }) => { + const { t } = useTranslation(); + const setModal = useSetModal(); + + const handleAssociation = useEffectEvent((e) => { + e.stopPropagation(); + setModal(<AssignExtensionModal defaultUsername={username} onClose={(): void => setModal(null)} />); + }); + + return ( + <GenericTableCell fontScale='p2' color='hint' withTruncatedText> + <IconButton icon='user-plus' small title={t('Associate_Extension')} onClick={handleAssociation} /> + </GenericTableCell> + ); +}; + +export default AssignExtensionButton; diff --git a/apps/meteor/client/views/admin/users/voip/AssignExtensionModal.spec.tsx b/apps/meteor/client/views/admin/users/voip/AssignExtensionModal.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a1ed4b4d4376c4e50a12e6a86495a434f1c3be16 --- /dev/null +++ b/apps/meteor/client/views/admin/users/voip/AssignExtensionModal.spec.tsx @@ -0,0 +1,129 @@ +/* eslint-disable testing-library/no-await-sync-events */ +import { faker } from '@faker-js/faker'; +import { UserStatus } from '@rocket.chat/core-typings'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import AssignExtensionModal from './AssignExtensionModal'; + +const appRoot = mockAppRoot() + .withJohnDoe() + .withEndpoint('POST', '/v1/voip-freeswitch.extension.assign', () => null) + .withEndpoint('GET', '/v1/voip-freeswitch.extension.list', () => ({ + extensions: [ + { + extension: '1000', + context: 'default', + domain: '172.31.38.45', + groups: ['default', 'sales'], + status: 'UNREGISTERED' as const, + contact: 'error/user_not_registered', + callGroup: 'techsupport', + callerName: 'Extension 1000', + callerNumber: '1000', + }, + ], + success: true, + })) + .withEndpoint('GET', '/v1/users.autocomplete', () => ({ + items: [ + { + _id: faker.database.mongodbObjectId(), + name: 'Jane Doe', + username: 'jane.doe', + nickname: '', + status: UserStatus.OFFLINE, + avatarETag: '', + }, + ], + success: true, + })); + +it.todo('should load with default user'); + +it.todo('should load with default extension'); + +it('should only enable "Free Extension Numbers" field if username is informed', async () => { + render(<AssignExtensionModal onClose={() => undefined} />, { + legacyRoot: true, + wrapper: appRoot.build(), + }); + + const extensionsSelect = screen.getByRole('button', { name: /Select_an_option/i }); + expect(extensionsSelect).toHaveClass('disabled'); + expect(screen.getByLabelText('User')).toBeEnabled(); + + screen.getByLabelText('User').focus(); + const userOption = await screen.findByRole('option', { name: 'Jane Doe' }); + await userEvent.click(userOption); + + await waitFor(() => expect(extensionsSelect).not.toHaveClass('disabled')); +}); + +it('should only enable "Associate" button both username and extension is informed', async () => { + render(<AssignExtensionModal onClose={() => undefined} />, { + legacyRoot: true, + wrapper: appRoot.build(), + }); + + expect(screen.getByRole('button', { name: /Associate/i, hidden: true })).toBeDisabled(); + + screen.getByLabelText('User').focus(); + const userOption = await screen.findByRole('option', { name: 'Jane Doe' }); + await userEvent.click(userOption); + + const extensionsSelect = screen.getByRole('button', { name: /Select_an_option/i }); + await waitFor(() => expect(extensionsSelect).not.toHaveClass('disabled')); + + extensionsSelect.click(); + const extOption = await screen.findByRole('option', { name: '1000' }); + await userEvent.click(extOption); + + expect(screen.getByRole('button', { name: /Associate/i, hidden: true })).toBeEnabled(); +}); + +it('should call onClose when extension is associated', async () => { + const closeFn = jest.fn(); + render(<AssignExtensionModal onClose={closeFn} />, { + legacyRoot: true, + wrapper: appRoot.build(), + }); + + screen.getByLabelText('User').focus(); + const userOption = await screen.findByRole('option', { name: 'Jane Doe' }); + await userEvent.click(userOption); + + const extensionsSelect = screen.getByRole('button', { name: /Select_an_option/i }); + await waitFor(() => expect(extensionsSelect).not.toHaveClass('disabled')); + + extensionsSelect.click(); + const extOption = await screen.findByRole('option', { name: '1000' }); + await userEvent.click(extOption); + + screen.getByRole('button', { name: /Associate/i, hidden: true }).click(); + await waitFor(() => expect(closeFn).toHaveBeenCalled()); +}); + +it('should call onClose when cancel button is clicked', () => { + const closeFn = jest.fn(); + render(<AssignExtensionModal onClose={closeFn} />, { + legacyRoot: true, + wrapper: appRoot.build(), + }); + + screen.getByRole('button', { name: /Cancel/i, hidden: true }).click(); + expect(closeFn).toHaveBeenCalled(); +}); + +it('should call onClose when cancel button is clicked', () => { + const closeFn = jest.fn(); + render(<AssignExtensionModal onClose={closeFn} />, { + legacyRoot: true, + wrapper: appRoot.build(), + }); + + screen.getByRole('button', { name: /Close/i, hidden: true }).click(); + expect(closeFn).toHaveBeenCalled(); +}); diff --git a/apps/meteor/client/views/admin/users/voip/AssignExtensionModal.tsx b/apps/meteor/client/views/admin/users/voip/AssignExtensionModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e13731028d1ddb5bad0d2e0354e9b3cc89dea49 --- /dev/null +++ b/apps/meteor/client/views/admin/users/voip/AssignExtensionModal.tsx @@ -0,0 +1,148 @@ +import { Button, Modal, Select, Field, FieldGroup, FieldLabel, FieldRow, Box } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { UserAutoComplete } from '@rocket.chat/ui-client'; +import { useToastMessageDispatch, useEndpoint, useUser } from '@rocket.chat/ui-contexts'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import React, { useMemo } from 'react'; +import { Controller, useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +type AssignExtensionModalProps = { + onClose: () => void; + defaultExtension?: string; + defaultUsername?: string; +}; + +type FormValue = { + username: string; + extension: string; +}; + +const AssignExtensionModal = ({ defaultExtension, defaultUsername, onClose }: AssignExtensionModalProps) => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const queryClient = useQueryClient(); + + const loggedUser = useUser(); + + const assignUser = useEndpoint('POST', '/v1/voip-freeswitch.extension.assign'); + const getAvailableExtensions = useEndpoint('GET', '/v1/voip-freeswitch.extension.list'); + + const modalTitleId = useUniqueId(); + const usersWithoutExtensionsId = useUniqueId(); + const freeExtensionNumberId = useUniqueId(); + + const { + control, + handleSubmit, + formState: { isSubmitting }, + } = useForm<FormValue>({ + defaultValues: { + username: defaultUsername, + extension: defaultExtension, + }, + }); + + const selectedUsername = useWatch({ control, name: 'username' }); + const selectedExtension = useWatch({ control, name: 'extension' }); + + const { data: availableExtensions = [], isLoading } = useQuery( + ['/v1/voip-freeswitch.extension.list', selectedUsername], + () => getAvailableExtensions({ type: 'available' as const, username: selectedUsername }), + { + select: (data) => data.extensions || [], + enabled: !!selectedUsername, + }, + ); + + const extensionOptions = useMemo<[string, string][]>( + () => availableExtensions.map(({ extension }) => [extension, extension]), + [availableExtensions], + ); + + const handleAssignment = useMutation({ + mutationFn: async ({ username, extension }: FormValue) => { + await assignUser({ username, extension }); + + queryClient.invalidateQueries(['users.list']); + if (loggedUser?.username === username) { + queryClient.invalidateQueries(['voip-client']); + } + + onClose(); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + onClose(); + }, + }); + + return ( + <Modal + aria-labelledby={modalTitleId} + wrapperFunction={(props) => <Box is='form' onSubmit={handleSubmit((data) => handleAssignment.mutateAsync(data))} {...props} />} + > + <Modal.Header> + <Modal.Title id={modalTitleId}>{t('Assign_extension')}</Modal.Title> + <Modal.Close aria-label={t('Close')} onClick={onClose} /> + </Modal.Header> + <Modal.Content> + <FieldGroup> + <Field> + <FieldLabel htmlFor={usersWithoutExtensionsId}>{t('User')}</FieldLabel> + <FieldRow> + <Controller + control={control} + name='username' + render={({ field }) => ( + <UserAutoComplete + id={usersWithoutExtensionsId} + value={field.value} + onChange={field.onChange} + conditions={{ + $or: [ + { freeSwitchExtension: { $exists: true, $eq: selectedExtension } }, + { freeSwitchExtension: { $exists: false } }, + { username: { $exists: true, $eq: selectedUsername } }, + ], + }} + /> + )} + /> + </FieldRow> + </Field> + + <Field> + <FieldLabel htmlFor={freeExtensionNumberId}>{t('Free_Extension_Numbers')}</FieldLabel> + <FieldRow> + <Controller + control={control} + name='extension' + render={({ field }) => ( + <Select + id={freeExtensionNumberId} + disabled={isLoading || !selectedUsername} + options={extensionOptions} + placeholder={t('Select_an_option')} + value={field.value} + onChange={field.onChange} + /> + )} + /> + </FieldRow> + </Field> + </FieldGroup> + </Modal.Content> + <Modal.Footer> + <Modal.FooterControllers> + <Button onClick={onClose}>{t('Cancel')}</Button> + <Button primary disabled={!selectedUsername || !selectedExtension} loading={isSubmitting} type='submit'> + {t('Associate')} + </Button> + </Modal.FooterControllers> + </Modal.Footer> + </Modal> + ); +}; + +export default AssignExtensionModal; diff --git a/apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.spec.tsx b/apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..73e9abe9693a7659fde54dc49a071f3c3315027e --- /dev/null +++ b/apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.spec.tsx @@ -0,0 +1,54 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import '@testing-library/jest-dom'; +import RemoveExtensionModal from './RemoveExtensionModal'; + +const appRoot = mockAppRoot().withJohnDoe(); + +it('should have user and extension informed', async () => { + render(<RemoveExtensionModal name='John Doe' username='john.doe' extension='1000' onClose={() => undefined} />, { + legacyRoot: true, + wrapper: appRoot.build(), + }); + + expect(screen.getByLabelText('User')).toHaveValue('John Doe'); + expect(screen.getByLabelText('Extension')).toHaveValue('1000'); +}); + +it('should call assign endpoint and onClose when extension is removed', async () => { + const closeFn = jest.fn(); + const assignFn = jest.fn(() => null); + render(<RemoveExtensionModal name='John Doe' username='john.doe' extension='1000' onClose={closeFn} />, { + legacyRoot: true, + wrapper: appRoot.withEndpoint('POST', '/v1/voip-freeswitch.extension.assign', assignFn).build(), + }); + + screen.getByRole('button', { name: /Remove/i, hidden: true }).click(); + + await waitFor(() => expect(assignFn).toHaveBeenCalled()); + await waitFor(() => expect(closeFn).toHaveBeenCalled()); +}); + +it('should call onClose when cancel button is clicked', () => { + const closeFn = jest.fn(); + render(<RemoveExtensionModal name='John Doe' username='john.doe' extension='1000' onClose={closeFn} />, { + legacyRoot: true, + wrapper: appRoot.build(), + }); + + screen.getByRole('button', { name: /Cancel/i, hidden: true }).click(); + expect(closeFn).toHaveBeenCalled(); +}); + +it('should call onClose when cancel button is clicked', () => { + const closeFn = jest.fn(); + render(<RemoveExtensionModal name='John Doe' username='john.doe' extension='1000' onClose={closeFn} />, { + legacyRoot: true, + wrapper: appRoot.build(), + }); + + screen.getByRole('button', { name: /Close/i, hidden: true }).click(); + expect(closeFn).toHaveBeenCalled(); +}); diff --git a/apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.tsx b/apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bd93ce9ebef10794e939ee70fb94a005e56cb1ca --- /dev/null +++ b/apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.tsx @@ -0,0 +1,81 @@ +import { Button, Modal, Field, FieldGroup, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useToastMessageDispatch, useEndpoint, useUser } from '@rocket.chat/ui-contexts'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +type RemoveExtensionModalProps = { + name: string; + extension: string; + username: string; + onClose: () => void; +}; + +const RemoveExtensionModal = ({ name, extension, username, onClose }: RemoveExtensionModalProps) => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const queryClient = useQueryClient(); + + const loggedUser = useUser(); + + const removeExtension = useEndpoint('POST', '/v1/voip-freeswitch.extension.assign'); + + const modalTitleId = useUniqueId(); + const userFieldId = useUniqueId(); + const freeExtensionNumberId = useUniqueId(); + + const handleRemoveExtension = useMutation({ + mutationFn: (username: string) => removeExtension({ username }), + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('Extension_removed') }); + + queryClient.invalidateQueries(['users.list']); + if (loggedUser?.username === username) { + queryClient.invalidateQueries(['voip-client']); + } + + onClose(); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + onClose(); + }, + }); + + return ( + <Modal aria-labelledby={modalTitleId}> + <Modal.Header> + <Modal.Title id={modalTitleId}>{t('Remove_extension')}</Modal.Title> + <Modal.Close aria-label={t('Close')} onClick={onClose} /> + </Modal.Header> + <Modal.Content> + <FieldGroup> + <Field> + <FieldLabel htmlFor={userFieldId}>{t('User')}</FieldLabel> + <FieldRow> + <TextInput disabled id={userFieldId} value={name} /> + </FieldRow> + </Field> + + <Field> + <FieldLabel htmlFor={freeExtensionNumberId}>{t('Extension')}</FieldLabel> + <FieldRow> + <TextInput disabled id={freeExtensionNumberId} value={extension} /> + </FieldRow> + </Field> + </FieldGroup> + </Modal.Content> + <Modal.Footer> + <Modal.FooterControllers> + <Button onClick={onClose}>{t('Cancel')}</Button> + <Button danger onClick={() => handleRemoveExtension.mutate(username)} loading={handleRemoveExtension.isLoading}> + {t('Remove')} + </Button> + </Modal.FooterControllers> + </Modal.Footer> + </Modal> + ); +}; + +export default RemoveExtensionModal; diff --git a/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx b/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx index a26b5f31e77d66ac523681673767640b49ce050a..feaf12fd6b04e16b6c60615cc17ece955cc5cf32 100644 --- a/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx +++ b/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx @@ -48,6 +48,7 @@ const UserCardWithData = ({ username, rid, onOpenUserInfo, onClose }: UserCardWi utcOffset = defaultValue, nickname, avatarETag, + freeSwitchExtension, } = data?.user || {}; return { @@ -61,6 +62,7 @@ const UserCardWithData = ({ username, rid, onOpenUserInfo, onClose }: UserCardWi status: _id && <ReactiveUserStatus uid={_id} />, customStatus: statusText, nickname, + freeSwitchExtension, }; }, [data, username, showRealNames, isLoading, getRoles]); @@ -69,13 +71,13 @@ const UserCardWithData = ({ username, rid, onOpenUserInfo, onClose }: UserCardWi onClose(); }); - const { actions: actionsDefinition, menuActions: menuOptions } = useUserInfoActions( - { _id: user._id ?? '', username: user.username, name: user.name }, + const { actions: actionsDefinition, menuActions: menuOptions } = useUserInfoActions({ rid, - refetch, - undefined, + user: { _id: user._id ?? '', username: user.username, name: user.name, freeSwitchExtension: user.freeSwitchExtension }, + size: 3, isMember, - ); + reload: refetch, + }); const menu = useMemo(() => { if (!menuOptions?.length) { @@ -95,8 +97,8 @@ const UserCardWithData = ({ username, rid, onOpenUserInfo, onClose }: UserCardWi }, [menuOptions, onClose, t]); const actions = useMemo(() => { - const mapAction = ([key, { content, icon, onClick }]: any): ReactElement => ( - <UserCardAction key={key} label={content} aria-label={content} onClick={onClick} icon={icon} /> + const mapAction = ([key, { content, title, icon, onClick }]: any): ReactElement => ( + <UserCardAction key={key} label={content || title} aria-label={content || title} onClick={onClick} icon={icon} /> ); return [...actionsDefinition.map(mapAction), menu].filter(Boolean); diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembers.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembers.tsx index 214dff25dfac2e643b0090cea970808634116977..d14c5d8a692cc1c56b5c9cf1da7a0fbc6f5b6595 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembers.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembers.tsx @@ -21,7 +21,7 @@ import { VirtuosoScrollbars } from '../../../../components/CustomScrollbars'; import InfiniteListAnchor from '../../../../components/InfiniteListAnchor'; import RoomMembersRow from './RoomMembersRow'; -type RoomMemberUser = Pick<IUser, 'username' | '_id' | 'name' | 'status'>; +type RoomMemberUser = Pick<IUser, 'username' | '_id' | 'name' | 'status' | 'freeSwitchExtension'>; type RoomMembersProps = { rid: IRoom['_id']; diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx index 453f55fc71a310976c3a720f724f87708917a176..a9ba5dd9dc0c11d266567e61712103915fd3f694 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx @@ -6,17 +6,22 @@ import { useTranslation } from 'react-i18next'; import { useUserInfoActions } from '../../hooks/useUserInfoActions'; -type RoomMembersActionsProps = { - username: IUser['username']; - name: IUser['name']; - _id: IUser['_id']; +type RoomMembersActionsProps = Pick<IUser, '_id' | 'name' | 'username' | 'freeSwitchExtension'> & { rid: IRoom['_id']; reload: () => void; }; -const RoomMembersActions = ({ username, _id, name, rid, reload }: RoomMembersActionsProps): ReactElement | null => { +const RoomMembersActions = ({ username, _id, name, rid, freeSwitchExtension, reload }: RoomMembersActionsProps): ReactElement | null => { const { t } = useTranslation(); - const { menuActions: menuOptions } = useUserInfoActions({ _id, username, name }, rid, reload, 0, true); + + const { menuActions: menuOptions } = useUserInfoActions({ + rid, + user: { _id, username, name, freeSwitchExtension }, + reload, + size: 0, + isMember: true, + }); + if (!menuOptions) { return null; } diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersItem.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersItem.tsx index 8b4d5ad8cb528b3b7fcb13fe8b4b053238c911b5..562d36536dbe0c2e849fe70273aababbc6485a9c 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersItem.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersItem.tsx @@ -25,9 +25,19 @@ type RoomMembersItemProps = { rid: IRoom['_id']; reload: () => void; useRealName: boolean; -} & Pick<IUser, 'federated' | 'username' | 'name' | '_id'>; +} & Pick<IUser, 'federated' | 'username' | 'name' | '_id' | 'freeSwitchExtension'>; -const RoomMembersItem = ({ _id, name, username, federated, onClickView, rid, reload, useRealName }: RoomMembersItemProps): ReactElement => { +const RoomMembersItem = ({ + _id, + name, + username, + federated, + freeSwitchExtension, + onClickView, + rid, + reload, + useRealName, +}: RoomMembersItemProps): ReactElement => { const [showButton, setShowButton] = useState(); const isReduceMotionEnabled = usePrefersReducedMotion(); @@ -50,7 +60,7 @@ const RoomMembersItem = ({ _id, name, username, federated, onClickView, rid, rel </OptionContent> <OptionMenu onClick={preventPropagation}> {showButton ? ( - <UserActions username={username} name={name} rid={rid} _id={_id} reload={reload} /> + <UserActions username={username} name={name} rid={rid} _id={_id} freeSwitchExtension={freeSwitchExtension} reload={reload} /> ) : ( <IconButton tiny icon='kebab' /> )} diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersRow.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersRow.tsx index 90c827194c2491ce4c36d73f5f5dbb2d7168ca98..acac41abf1968eb8e9a3e676d8aee97a1692eecb 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersRow.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersRow.tsx @@ -5,7 +5,7 @@ import React, { memo } from 'react'; import RoomMembersItem from './RoomMembersItem'; type RoomMembersRowProps = { - user: Pick<IUser, 'federated' | 'username' | 'name' | '_id'>; + user: Pick<IUser, 'federated' | 'username' | 'name' | '_id' | 'freeSwitchExtension'>; data: { onClickView: (e: MouseEvent<HTMLElement>) => void; rid: IRoom['_id']; @@ -29,6 +29,7 @@ const RoomMembersRow = ({ user, data: { onClickView, rid }, index, reload, useRe rid={rid} name={user.name} federated={user.federated} + freeSwitchExtension={user.freeSwitchExtension} onClickView={onClickView} reload={reload} /> diff --git a/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx b/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx index b60bcb749f5b08a74da2f3d83d407b486afa8786..bf600a757f26c59ece242fc28998971623265ab0 100644 --- a/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx +++ b/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx @@ -11,7 +11,7 @@ import { useMemberExists } from '../../../hooks/useMemberExists'; import { useUserInfoActions } from '../../hooks/useUserInfoActions'; type UserInfoActionsProps = { - user: Pick<IUser, '_id' | 'username' | 'name'>; + user: Pick<IUser, '_id' | 'username' | 'name' | 'freeSwitchExtension'>; rid: IRoom['_id']; backToList: () => void; }; @@ -26,17 +26,18 @@ const UserInfoActions = ({ user, rid, backToList }: UserInfoActionsProps): React } = useMemberExists({ roomId: rid, username: user.username as string }); const isMember = membershipCheckSuccess && isMemberData?.isMember; + const { _id: userId, username, name, freeSwitchExtension } = user; - const { actions: actionsDefinition, menuActions: menuOptions } = useUserInfoActions( - { _id: user._id, username: user.username, name: user.name }, + const { actions: actionsDefinition, menuActions: menuOptions } = useUserInfoActions({ rid, - () => { + user: { _id: userId, username, name, freeSwitchExtension }, + size: 3, + isMember, + reload: () => { backToList?.(); refetch(); }, - undefined, - isMember, - ); + }); const menu = useMemo(() => { if (!menuOptions?.length) { @@ -59,8 +60,8 @@ const UserInfoActions = ({ user, rid, backToList }: UserInfoActionsProps): React // TODO: sanitize Action type to avoid any const actions = useMemo(() => { - const mapAction = ([key, { content, icon, onClick }]: any): ReactElement => ( - <UserInfoAction key={key} title={content} label={content} onClick={onClick} icon={icon} /> + const mapAction = ([key, { content, title, icon, onClick }]: any): ReactElement => ( + <UserInfoAction key={key} title={title} label={content} onClick={onClick} icon={icon} /> ); return [...actionsDefinition.map(mapAction), menu].filter(Boolean); diff --git a/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoWithData.tsx b/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoWithData.tsx index b5049bebdc1f6affaddc020475a7553245a9ccf4..8f35b56a1c33840307a39b66622b891dd4e0d905 100644 --- a/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoWithData.tsx +++ b/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoWithData.tsx @@ -61,6 +61,7 @@ const UserInfoWithData = ({ uid, username, rid, onClose, onClickBack }: UserInfo nickname, createdAt, canViewAllInfo, + freeSwitchExtension, } = data.user; return { @@ -80,6 +81,7 @@ const UserInfoWithData = ({ uid, username, rid, onClose, onClickBack }: UserInfo status: <ReactiveUserStatus uid={_id} />, statusText, nickname, + freeSwitchExtension, }; }, [data, getRoles]); diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useCallAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVideoCallAction.tsx similarity index 85% rename from apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useCallAction.tsx rename to apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVideoCallAction.tsx index 27fd6de58520974b39848581f7e56dc28f0d3853..8b3f0e5307b5e654f28afd5911d487a57a74bc58 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useCallAction.tsx +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVideoCallAction.tsx @@ -7,9 +7,9 @@ import { useVideoConfDispatchOutgoing, useVideoConfIsCalling, useVideoConfIsRing import { VideoConfManager } from '../../../../../lib/VideoConfManager'; import { useUserCard } from '../../../contexts/UserCardContext'; import { useVideoConfWarning } from '../../../contextualBar/VideoConference/hooks/useVideoConfWarning'; -import type { UserInfoAction, UserInfoActionType } from '../useUserInfoActions'; +import type { UserInfoAction } from '../useUserInfoActions'; -export const useCallAction = (user: Pick<IUser, '_id' | 'username'>): UserInfoAction | undefined => { +export const useVideoCallAction = (user: Pick<IUser, '_id' | 'username'>): UserInfoAction | undefined => { const t = useTranslation(); const usernameSubscription = useUserSubscriptionByName(user.username ?? ''); const room = useUserRoom(usernameSubscription?.rid || ''); @@ -24,7 +24,7 @@ export const useCallAction = (user: Pick<IUser, '_id' | 'username'>): UserInfoAc const enabledForDMs = useSetting('VideoConf_Enable_DMs'); const permittedToCallManagement = usePermission('call-management', room?._id); - const videoCallOption = useMemo(() => { + const videoCallOption = useMemo<UserInfoAction | undefined>(() => { const action = async (): Promise<void> => { if (isCalling || isRinging || !room) { return; @@ -44,10 +44,10 @@ export const useCallAction = (user: Pick<IUser, '_id' | 'username'>): UserInfoAc return shouldShowStartCall ? { - content: t('Start_call'), - icon: 'phone' as const, + type: 'communication', + title: t('Video_call'), + icon: 'video', onClick: action, - type: 'communication' as UserInfoActionType, } : undefined; }, [ diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da02cf26e5607405edd53e77485143927d386328 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx @@ -0,0 +1,43 @@ +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<IUser, '_id' | 'username' | 'freeSwitchExtension'>): 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<UserInfoAction | undefined>(() => { + 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/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts index c04df6b7521a92bf892e28f3d1f6474f709b95a9..90dbfbbaba4ca8b38f5f6f6c936e2c425ecfcb14 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts @@ -8,7 +8,6 @@ import { useMemo } from 'react'; import { useEmbeddedLayout } from '../../../../hooks/useEmbeddedLayout'; import { useAddUserAction } from './actions/useAddUserAction'; import { useBlockUserAction } from './actions/useBlockUserAction'; -import { useCallAction } from './actions/useCallAction'; import { useChangeLeaderAction } from './actions/useChangeLeaderAction'; import { useChangeModeratorAction } from './actions/useChangeModeratorAction'; import { useChangeOwnerAction } from './actions/useChangeOwnerAction'; @@ -18,30 +17,52 @@ import { useMuteUserAction } from './actions/useMuteUserAction'; import { useRedirectModerationConsole } from './actions/useRedirectModerationConsole'; import { useRemoveUserAction } from './actions/useRemoveUserAction'; import { useReportUser } from './actions/useReportUser'; +import { useVideoCallAction } from './actions/useVideoCallAction'; +import { useVoipCallAction } from './actions/useVoipCallAction'; export type UserInfoActionType = 'communication' | 'privileges' | 'management' | 'moderation'; -export type UserInfoAction = { - content: string; - icon?: ComponentProps<typeof Icon>['name']; +type UserInfoActionWithOnlyIcon = { + type?: UserInfoActionType; + content?: string; + icon: ComponentProps<typeof Icon>['name']; + title: string; + variant?: 'danger'; onClick: () => void; +}; + +type UserInfoActionWithContent = { type?: UserInfoActionType; + content: string; + icon?: ComponentProps<typeof Icon>['name']; + title?: string; variant?: 'danger'; + onClick: () => void; }; +export type UserInfoAction = UserInfoActionWithContent | UserInfoActionWithOnlyIcon; + type UserMenuAction = { id: string; title: string; items: GenericMenuItemProps[]; }[]; -export const useUserInfoActions = ( - user: Pick<IUser, '_id' | 'username' | 'name'>, - rid: IRoom['_id'], - reload?: () => void, +type UserInfoActionsParams = { + user: Pick<IUser, '_id' | 'username' | 'name' | 'freeSwitchExtension'>; + rid: IRoom['_id']; + reload?: () => void; + size?: number; + isMember?: boolean; +}; + +export const useUserInfoActions = ({ + user, + rid, + reload, size = 2, - isMember?: boolean, -): { actions: [string, UserInfoAction][]; menuActions: any | undefined } => { + isMember, +}: UserInfoActionsParams): { actions: [string, UserInfoAction][]; menuActions: any | undefined } => { const addUser = useAddUserAction(user, rid, reload); const blockUser = useBlockUserAction(user, rid); const changeLeader = useChangeLeaderAction(user, rid); @@ -52,7 +73,8 @@ export const useUserInfoActions = ( const ignoreUser = useIgnoreUserAction(user, rid); const muteUser = useMuteUserAction(user, rid); const removeUser = useRemoveUserAction(user, rid, reload); - const call = useCallAction(user); + const videoCall = useVideoCallAction(user); + const voipCall = useVoipCallAction(user); const reportUserOption = useReportUser(user); const isLayoutEmbedded = useEmbeddedLayout(); const { userToolbox: hiddenActions } = useLayoutHiddenActions(); @@ -60,7 +82,8 @@ export const useUserInfoActions = ( const userinfoActions = useMemo( () => ({ ...(openDirectMessage && !isLayoutEmbedded && { openDirectMessage }), - ...(call && { call }), + ...(videoCall && { videoCall }), + ...(voipCall && { voipCall }), ...(!isMember && addUser && { addUser }), ...(isMember && changeOwner && { changeOwner }), ...(isMember && changeLeader && { changeLeader }), @@ -75,7 +98,8 @@ export const useUserInfoActions = ( [ openDirectMessage, isLayoutEmbedded, - call, + videoCall, + voipCall, changeOwner, changeLeader, changeModerator, @@ -100,7 +124,12 @@ export const useUserInfoActions = ( const group = item.type ? item.type : ''; const section = acc.find((section: { id: string }) => section.id === group); - const newItem = { ...item, id: item.content }; + const newItem = { + ...item, + id: item.content || item.title || '', + content: item.content || item.title, + }; + if (section) { section.items.push(newItem); return acc; diff --git a/apps/meteor/ee/app/api-enterprise/server/index.ts b/apps/meteor/ee/app/api-enterprise/server/index.ts index 7a528a4ec2f43a42a497e231653569e82a497387..1c48d592d33e97b955c09fc04c83b2dc5582f199 100644 --- a/apps/meteor/ee/app/api-enterprise/server/index.ts +++ b/apps/meteor/ee/app/api-enterprise/server/index.ts @@ -3,3 +3,7 @@ import { License } from '@rocket.chat/license'; await License.onLicense('canned-responses', async () => { await import('./canned-responses'); }); + +await License.onLicense('voip-enterprise', async () => { + await import('./voip-freeswitch'); +}); diff --git a/apps/meteor/ee/app/api-enterprise/server/voip-freeswitch.ts b/apps/meteor/ee/app/api-enterprise/server/voip-freeswitch.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4857896e01d58c4f56dba913d3d39141de7b15d --- /dev/null +++ b/apps/meteor/ee/app/api-enterprise/server/voip-freeswitch.ts @@ -0,0 +1,150 @@ +import { VoipFreeSwitch } from '@rocket.chat/core-services'; +import { Users } from '@rocket.chat/models'; +import { + isVoipFreeSwitchExtensionAssignProps, + isVoipFreeSwitchExtensionGetDetailsProps, + isVoipFreeSwitchExtensionGetInfoProps, + isVoipFreeSwitchExtensionListProps, +} from '@rocket.chat/rest-typings'; +import { wrapExceptions } from '@rocket.chat/tools'; + +import { API } from '../../../../app/api/server'; +import { settings } from '../../../../app/settings/server/cached'; + +API.v1.addRoute( + 'voip-freeswitch.extension.list', + { authRequired: true, permissionsRequired: ['manage-voip-extensions'], validateParams: isVoipFreeSwitchExtensionListProps }, + { + async get() { + const { username, type = 'all' } = this.queryParams; + + const extensions = await wrapExceptions(() => VoipFreeSwitch.getExtensionList()).catch(() => { + throw new Error('Failed to load extension list.'); + }); + + if (type === 'all') { + return API.v1.success({ extensions }); + } + + const assignedExtensions = await Users.findAssignedFreeSwitchExtensions().toArray(); + + switch (type) { + case 'free': + const freeExtensions = extensions.filter(({ extension }) => !assignedExtensions.includes(extension)); + return API.v1.success({ extensions: freeExtensions }); + case 'allocated': + // Extensions that are already assigned to some user + const allocatedExtensions = extensions.filter(({ extension }) => assignedExtensions.includes(extension)); + return API.v1.success({ extensions: allocatedExtensions }); + case 'available': + // Extensions that are free or assigned to the specified user + const user = (username && (await Users.findOneByUsername(username, { projection: { freeSwitchExtension: 1 } }))) || undefined; + const currentExtension = user?.freeSwitchExtension; + + const availableExtensions = extensions.filter( + ({ extension }) => extension === currentExtension || !assignedExtensions.includes(extension), + ); + + return API.v1.success({ extensions: availableExtensions }); + } + + return API.v1.success({ extensions }); + }, + }, +); + +API.v1.addRoute( + 'voip-freeswitch.extension.assign', + { authRequired: true, permissionsRequired: ['manage-voip-extensions'], validateParams: isVoipFreeSwitchExtensionAssignProps }, + { + async post() { + const { extension, username } = this.bodyParams; + + if (!username) { + return API.v1.notFound(); + } + + const user = await Users.findOneByUsername(username, { projection: { freeSwitchExtension: 1 } }); + if (!user) { + return API.v1.notFound(); + } + + const existingUser = extension && (await Users.findOneByFreeSwitchExtension(extension, { projection: { _id: 1 } })); + if (existingUser && existingUser._id !== user._id) { + throw new Error('Extension not available.'); + } + + if (extension && user.freeSwitchExtension === extension) { + return API.v1.success(); + } + + await Users.setFreeSwitchExtension(user._id, extension); + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'voip-freeswitch.extension.getDetails', + { authRequired: true, permissionsRequired: ['view-voip-extension-details'], validateParams: isVoipFreeSwitchExtensionGetDetailsProps }, + { + async get() { + const { extension, group } = this.queryParams; + + if (!extension) { + throw new Error('Invalid params'); + } + + const extensionData = await wrapExceptions(() => VoipFreeSwitch.getExtensionDetails({ extension, group })).suppress(() => undefined); + if (!extensionData) { + return API.v1.notFound(); + } + + const existingUser = await Users.findOneByFreeSwitchExtension(extensionData.extension, { projection: { username: 1, name: 1 } }); + + return API.v1.success({ + ...extensionData, + ...(existingUser && { userId: existingUser._id, name: existingUser.name, username: existingUser.username }), + }); + }, + }, +); + +API.v1.addRoute( + 'voip-freeswitch.extension.getRegistrationInfoByUserId', + { authRequired: true, permissionsRequired: ['view-user-voip-extension'], validateParams: isVoipFreeSwitchExtensionGetInfoProps }, + { + async get() { + const { userId } = this.queryParams; + + if (!userId) { + throw new Error('Invalid params.'); + } + + const user = await Users.findOneById(userId, { projection: { freeSwitchExtension: 1 } }); + if (!user) { + throw new Error('User not found.'); + } + + const { freeSwitchExtension: extension } = user; + + if (!extension) { + throw new Error('Extension not assigned.'); + } + + const extensionData = await wrapExceptions(() => VoipFreeSwitch.getExtensionDetails({ extension })).suppress(() => undefined); + if (!extensionData) { + return API.v1.notFound(); + } + const password = await wrapExceptions(() => VoipFreeSwitch.getUserPassword(extension)).suppress(() => undefined); + + return API.v1.success({ + extension: extensionData, + credentials: { + websocketPath: settings.get<string>('VoIP_TeamCollab_FreeSwitch_WebSocket_Path'), + password, + }, + }); + }, + }, +); diff --git a/apps/meteor/ee/server/configuration/index.ts b/apps/meteor/ee/server/configuration/index.ts index 9a7738b23e4a40a2a4faa8bc07ae37000b9c6561..09160ef39a26bea5a8dcb7d27325437b3ca095ca 100644 --- a/apps/meteor/ee/server/configuration/index.ts +++ b/apps/meteor/ee/server/configuration/index.ts @@ -3,3 +3,4 @@ import './oauth'; import './outlookCalendar'; import './saml'; import './videoConference'; +import './voip'; diff --git a/apps/meteor/ee/server/configuration/voip.ts b/apps/meteor/ee/server/configuration/voip.ts new file mode 100644 index 0000000000000000000000000000000000000000..b265ca900cdb4e652743a341e3e3fd0029d88b8b --- /dev/null +++ b/apps/meteor/ee/server/configuration/voip.ts @@ -0,0 +1,10 @@ +import { License } from '@rocket.chat/license'; +import { Meteor } from 'meteor/meteor'; + +import { addSettings } from '../settings/voip'; + +Meteor.startup(async () => { + await License.onLicense('voip-enterprise', async () => { + await addSettings(); + }); +}); diff --git a/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts b/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..8bc0f7c9b4fead4f39c3b9de41d4b52b9bbfa2be --- /dev/null +++ b/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts @@ -0,0 +1,53 @@ +import { type IVoipFreeSwitchService, ServiceClassInternal } from '@rocket.chat/core-services'; +import type { FreeSwitchExtension, ISetting, SettingValue } from '@rocket.chat/core-typings'; +import { getDomain, getUserPassword, getExtensionList, getExtensionDetails } from '@rocket.chat/freeswitch'; + +export class VoipFreeSwitchService extends ServiceClassInternal implements IVoipFreeSwitchService { + protected name = 'voip-freeswitch'; + + constructor(private getSetting: <T extends SettingValue = SettingValue>(id: ISetting['_id']) => T) { + super(); + } + + private getConnectionSettings(): { host: string; port: number; password: string; timeout: number } { + if (!this.getSetting('VoIP_TeamCollab_Enabled') && !process.env.FREESWITCHIP) { + throw new Error('VoIP is disabled.'); + } + + const host = process.env.FREESWITCHIP || this.getSetting<string>('VoIP_TeamCollab_FreeSwitch_Host'); + if (!host) { + throw new Error('VoIP is not properly configured.'); + } + + const port = this.getSetting<number>('VoIP_TeamCollab_FreeSwitch_Port') || 8021; + const timeout = this.getSetting<number>('VoIP_TeamCollab_FreeSwitch_Timeout') || 3000; + const password = this.getSetting<string>('VoIP_TeamCollab_FreeSwitch_Password'); + + return { + host, + port, + password, + timeout, + }; + } + + async getDomain(): Promise<string> { + const options = this.getConnectionSettings(); + return getDomain(options); + } + + async getUserPassword(user: string): Promise<string> { + const options = this.getConnectionSettings(); + return getUserPassword(options, user); + } + + async getExtensionList(): Promise<FreeSwitchExtension[]> { + const options = this.getConnectionSettings(); + return getExtensionList(options); + } + + async getExtensionDetails(requestParams: { extension: string; group?: string }): Promise<FreeSwitchExtension> { + const options = this.getConnectionSettings(); + return getExtensionDetails(options, requestParams); + } +} diff --git a/apps/meteor/ee/server/settings/voip.ts b/apps/meteor/ee/server/settings/voip.ts new file mode 100644 index 0000000000000000000000000000000000000000..90d951ead3f4669a63ce19313465fd30f6dfe244 --- /dev/null +++ b/apps/meteor/ee/server/settings/voip.ts @@ -0,0 +1,49 @@ +import { settingsRegistry } from '../../../app/settings/server'; + +export function addSettings(): Promise<void> { + return settingsRegistry.addGroup('VoIP_TeamCollab', async function () { + await this.with( + { + enterprise: true, + modules: ['voip-enterprise'], + }, + async function () { + await this.add('VoIP_TeamCollab_Enabled', false, { + type: 'boolean', + public: true, + invalidValue: false, + }); + + await this.add('VoIP_TeamCollab_FreeSwitch_Host', '', { + type: 'string', + public: true, + invalidValue: '', + }); + + await this.add('VoIP_TeamCollab_FreeSwitch_Port', 8021, { + type: 'int', + public: true, + invalidValue: 8021, + }); + + await this.add('VoIP_TeamCollab_FreeSwitch_Password', '', { + type: 'password', + public: true, + invalidValue: '', + }); + + await this.add('VoIP_TeamCollab_FreeSwitch_Timeout', 3000, { + type: 'int', + public: true, + invalidValue: 3000, + }); + + await this.add('VoIP_TeamCollab_FreeSwitch_WebSocket_Path', '', { + type: 'string', + public: true, + invalidValue: '', + }); + }, + ); + }); +} diff --git a/apps/meteor/ee/server/startup/services.ts b/apps/meteor/ee/server/startup/services.ts index 2f63ddba42a826004e1c130732501a6502732516..dc072fb1d9b07a4b3f0a5a624b6163896d9a2b37 100644 --- a/apps/meteor/ee/server/startup/services.ts +++ b/apps/meteor/ee/server/startup/services.ts @@ -1,6 +1,7 @@ import { api } from '@rocket.chat/core-services'; import { License } from '@rocket.chat/license'; +import { settings } from '../../../app/settings/server/cached'; import { isRunningMs } from '../../../server/lib/isRunningMs'; import { FederationService } from '../../../server/services/federation/service'; import { LicenseService } from '../../app/license/server/license.internalService'; @@ -10,6 +11,7 @@ import { FederationServiceEE } from '../local-services/federation/service'; import { InstanceService } from '../local-services/instance/service'; import { LDAPEEService } from '../local-services/ldap/service'; import { MessageReadsService } from '../local-services/message-reads/service'; +import { VoipFreeSwitchService } from '../local-services/voip-freeswitch/service'; // TODO consider registering these services only after a valid license is added api.registerService(new EnterpriseSettings()); @@ -17,6 +19,7 @@ api.registerService(new LDAPEEService()); api.registerService(new LicenseService()); api.registerService(new MessageReadsService()); api.registerService(new OmnichannelEE()); +api.registerService(new VoipFreeSwitchService((id) => settings.get(id))); // when not running micro services we want to start up the instance intercom if (!isRunningMs()) { diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index 8dc7fa033e03d3288fa0c7dcf2442511d2a7e218..b944cd79562005013920d0ab7306cfe564bb18ca 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -11,6 +11,7 @@ export default { testMatch: [ '<rootDir>/client/**/**.spec.[jt]s?(x)', + '<rootDir>/ee/client/**/**.spec.[jt]s?(x)', '<rootDir>/tests/unit/client/views/**/*.spec.{ts,tsx}', '<rootDir>/tests/unit/client/providers/**/*.spec.{ts,tsx}', ], diff --git a/apps/meteor/package.json b/apps/meteor/package.json index b5c9003b6974892c04fd61c5d910ef0df49a46a8..4a86673b1fe534b651cfe688c0470b6fa007e450 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -241,6 +241,7 @@ "@rocket.chat/favicon": "workspace:^", "@rocket.chat/forked-matrix-appservice-bridge": "^4.0.2", "@rocket.chat/forked-matrix-bot-sdk": "^0.6.0-beta.3", + "@rocket.chat/freeswitch": "workspace:^", "@rocket.chat/fuselage": "^0.59.1", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/fuselage-polyfills": "~0.31.25", @@ -284,6 +285,7 @@ "@rocket.chat/ui-kit": "workspace:~", "@rocket.chat/ui-theming": "workspace:^", "@rocket.chat/ui-video-conf": "workspace:^", + "@rocket.chat/ui-voip": "workspace:^", "@rocket.chat/web-ui-registration": "workspace:^", "@slack/bolt": "^3.14.0", "@slack/rtm-api": "^6.0.0", @@ -332,6 +334,7 @@ "emailreplyparser": "^0.0.5", "emoji-toolkit": "^7.0.1", "emojione": "^4.5.0", + "esl": "github:pierre-lehnen-rc/esl", "eventemitter3": "^4.0.7", "exif-be-gone": "^1.3.2", "express": "^4.17.3", diff --git a/apps/meteor/server/models/raw/Users.js b/apps/meteor/server/models/raw/Users.js index 0b78f0f9e45497e11789a1836c89a9a6777b179d..a5bd1d4fc16b7fa34bb3b63b0676f64ee6770315 100644 --- a/apps/meteor/server/models/raw/Users.js +++ b/apps/meteor/server/models/raw/Users.js @@ -66,6 +66,7 @@ export class UsersRaw extends BaseRaw { { key: { openBusinessHours: 1 }, sparse: true }, { key: { statusLivechat: 1 }, sparse: true }, { key: { extension: 1 }, sparse: true, unique: true }, + { key: { freeSwitchExtension: 1 }, sparse: true, unique: true }, { key: { language: 1 }, sparse: true }, { key: { 'active': 1, 'services.email2fa.enabled': 1 }, sparse: true }, // used by statistics { key: { 'active': 1, 'services.totp.enabled': 1 }, sparse: true }, // used by statistics @@ -713,8 +714,10 @@ export class UsersRaw extends BaseRaw { $nin: exceptions, }, }, + { + ...conditions, + }, ], - ...conditions, }; return this.find(query, options); @@ -2432,6 +2435,34 @@ export class UsersRaw extends BaseRaw { }); } + findOneByFreeSwitchExtension(freeSwitchExtension, options = {}) { + return this.findOne( + { + freeSwitchExtension, + }, + options, + ); + } + + findAssignedFreeSwitchExtensions() { + return this.findUsersWithAssignedFreeSwitchExtensions({ + projection: { + freeSwitchExtension: 1, + }, + }).map(({ freeSwitchExtension }) => freeSwitchExtension); + } + + findUsersWithAssignedFreeSwitchExtensions(options = {}) { + return this.find( + { + freeSwitchExtension: { + $exists: 1, + }, + }, + options, + ); + } + // UPDATE addImportIds(_id, importIds) { importIds = [].concat(importIds); @@ -2905,6 +2936,17 @@ export class UsersRaw extends BaseRaw { ); } + async setFreeSwitchExtension(_id, extension) { + return this.updateOne( + { + _id, + }, + { + ...(extension ? { $set: { freeSwitchExtension: extension } } : { $unset: { freeSwitchExtension: 1 } }), + }, + ); + } + // INSERT create(data) { const user = { diff --git a/apps/meteor/tests/e2e/channel-management.spec.ts b/apps/meteor/tests/e2e/channel-management.spec.ts index cd3ab365240ff2750b7faed8485f7f116838c24e..15a0ef13eff2050bbc5ccc1c36a7a9e631bf9325 100644 --- a/apps/meteor/tests/e2e/channel-management.spec.ts +++ b/apps/meteor/tests/e2e/channel-management.spec.ts @@ -51,7 +51,8 @@ test.describe.serial('channel-management', () => { await page.keyboard.press('Tab'); await page.keyboard.press('Space'); - await poHomeChannel.content.btnStartCall.waitFor(); + await page.keyboard.press('Space'); + await poHomeChannel.content.btnStartVideoCall.waitFor(); await page.keyboard.press('Tab'); await expect(page.getByRole('button', { name: 'Start call' })).toBeFocused(); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index 519d9a4102aac59295f0eed49b4c88ab8f097bb8..77c378281d9462eacae1e21e03a90d85fe231a8f 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -342,15 +342,19 @@ export class HomeContent { return this.page.locator('[data-qa-id="ToolBoxAction-phone"]'); } - get btnStartCall(): Locator { + get menuItemVideoCall(): Locator { + return this.page.locator('role=menuitem[name="Video call"]'); + } + + get btnStartVideoCall(): Locator { return this.page.locator('#video-conf-root .rcx-button--primary.rcx-button >> text="Start call"'); } - get btnDeclineCall(): Locator { + get btnDeclineVideoCall(): Locator { return this.page.locator('.rcx-button--secondary-danger.rcx-button >> text="Decline"'); } - ringCallText(text: string): Locator { + videoConfRingCallText(text: string): Locator { return this.page.locator(`#video-conf-root .rcx-box.rcx-box--full >> text="${text}"`); } diff --git a/apps/meteor/tests/e2e/video-conference-ring.spec.ts b/apps/meteor/tests/e2e/video-conference-ring.spec.ts index 3c6ba1730e3d4a8fec44ec110bdd5474e9fb9b16..0b26c16cffc611fd60f2eecc3a4dd30aee440bfc 100644 --- a/apps/meteor/tests/e2e/video-conference-ring.spec.ts +++ b/apps/meteor/tests/e2e/video-conference-ring.spec.ts @@ -34,12 +34,13 @@ test.describe('video conference ringing', () => { await auxContext.poHomeChannel.sidenav.openChat('user1'); await poHomeChannel.content.btnCall.click(); - await poHomeChannel.content.btnStartCall.click(); + await poHomeChannel.content.menuItemVideoCall.click(); + await poHomeChannel.content.btnStartVideoCall.click(); - await expect(poHomeChannel.content.ringCallText('Calling')).toBeVisible(); - await expect(auxContext.poHomeChannel.content.ringCallText('Incoming call from')).toBeVisible(); + await expect(poHomeChannel.content.videoConfRingCallText('Calling')).toBeVisible(); + await expect(auxContext.poHomeChannel.content.videoConfRingCallText('Incoming call from')).toBeVisible(); - await auxContext.poHomeChannel.content.btnDeclineCall.click(); + await auxContext.poHomeChannel.content.btnDeclineVideoCall.click(); await auxContext.page.close(); }); diff --git a/apps/meteor/tests/e2e/video-conference.spec.ts b/apps/meteor/tests/e2e/video-conference.spec.ts index f49786f77598dd8b157091bf1b7390c1eca995d8..e5ec8a6f8c1dccdebf0dbc5b04005d98ee5d8610 100644 --- a/apps/meteor/tests/e2e/video-conference.spec.ts +++ b/apps/meteor/tests/e2e/video-conference.spec.ts @@ -28,7 +28,8 @@ test.describe('video conference', () => { await poHomeChannel.sidenav.openChat(targetChannel); await poHomeChannel.content.btnCall.click(); - await poHomeChannel.content.btnStartCall.click(); + await poHomeChannel.content.menuItemVideoCall.click(); + await poHomeChannel.content.btnStartVideoCall.click(); await expect(poHomeChannel.content.videoConfMessageBlock.last()).toBeVisible(); }); @@ -44,7 +45,8 @@ test.describe('video conference', () => { await poHomeChannel.sidenav.openChat('user2'); await poHomeChannel.content.btnCall.click(); - await poHomeChannel.content.btnStartCall.click(); + await poHomeChannel.content.menuItemVideoCall.click(); + await poHomeChannel.content.btnStartVideoCall.click(); await expect(poHomeChannel.content.videoConfMessageBlock.last()).toBeVisible(); }); @@ -60,7 +62,8 @@ test.describe('video conference', () => { await poHomeChannel.sidenav.openChat(targetTeam); await poHomeChannel.content.btnCall.click(); - await poHomeChannel.content.btnStartCall.click(); + await poHomeChannel.content.menuItemVideoCall.click(); + await poHomeChannel.content.btnStartVideoCall.click(); await expect(poHomeChannel.content.videoConfMessageBlock.last()).toBeVisible(); }); @@ -76,7 +79,8 @@ test.describe('video conference', () => { await poHomeChannel.sidenav.openChat('rocketchat.internal.admin.test, user2'); await poHomeChannel.content.btnCall.click(); - await poHomeChannel.content.btnStartCall.click(); + await poHomeChannel.content.menuItemVideoCall.click(); + await poHomeChannel.content.btnStartVideoCall.click(); await expect(poHomeChannel.content.videoConfMessageBlock.last()).toBeVisible(); }); diff --git a/apps/meteor/tests/mocks/client/meteor.ts b/apps/meteor/tests/mocks/client/meteor.ts index d8340ae7b3e764e1acae4c989afb4d5fa729c5ee..aa55781bf674962979c538ec051b633d50ae76b3 100644 --- a/apps/meteor/tests/mocks/client/meteor.ts +++ b/apps/meteor/tests/mocks/client/meteor.ts @@ -2,8 +2,27 @@ import { jest } from '@jest/globals'; export const Meteor = { loginWithSamlToken: jest.fn((_token, callback: () => void) => callback()), + connection: { + _stream: { on: jest.fn() }, + }, + _localStorage: { + getItem: jest.fn(), + setItem: jest.fn(), + }, + users: {}, }; export const Mongo = { - Collection: class Collection {}, + Collection: class Collection { + findOne = jest.fn(); + }, }; + +export const Accounts = { + onLogin: jest.fn(), + onLogout: jest.fn(), +}; + +export const Tracker = { autorun: jest.fn() }; + +export const ReactiveVar = class ReactiveVar {}; diff --git a/apps/meteor/tests/unit/server/lib/freeswitch.tests.ts b/apps/meteor/tests/unit/server/lib/freeswitch.tests.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb020995c7f8e93b2f6c733c945386210bc0b3a5 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/freeswitch.tests.ts @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import { describe } from 'mocha'; + +import { settings } from '../../../../app/settings/server/cached'; +import { VoipFreeSwitchService } from '../../../../ee/server/local-services/voip-freeswitch/service'; + +const VoipFreeSwitch = new VoipFreeSwitchService((id) => settings.get(id)); + +// Those tests still need a proper freeswitch environment configured in order to run +// So for now they are being deliberately skipped on CI +describe.skip('VoIP', () => { + describe('FreeSwitch', () => { + it('should get a list of users from FreeSwitch', async () => { + const result = await VoipFreeSwitch.getExtensionList(); + + expect(result).to.be.an('array'); + expect(result[0]).to.be.an('object'); + expect(result[0].extension).to.be.a('string'); + }); + + it('should get a specific user from FreeSwitch', async () => { + const result = await VoipFreeSwitch.getExtensionDetails({ extension: '1001' }); + + expect(result).to.be.an('object'); + expect(result.extension).to.be.equal('1001'); + }); + + it('Should load user domain from FreeSwitch', async () => { + const result = await VoipFreeSwitch.getDomain(); + + expect(result).to.be.a('string').equal('rocket.chat'); + }); + + it('Should load user password from FreeSwitch', async () => { + const result = await VoipFreeSwitch.getUserPassword('1000'); + + expect(result).to.be.a('string'); + }); + }); +}); diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index 85722c98839fe9b6e2516c3628434026d4b5b7b2..34694b078e01b4680b1d89c3b263f3458c73899d 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -47,6 +47,7 @@ import type { UiKitCoreAppPayload, IUiKitCoreApp, IUiKitCoreAppService } from '. import type { ISendFileLivechatMessageParams, ISendFileMessageParams, IUploadFileParams, IUploadService } from './types/IUploadService'; import type { IUserService } from './types/IUserService'; import type { IVideoConfService, VideoConferenceJoinOptions } from './types/IVideoConfService'; +import type { IVoipFreeSwitchService } from './types/IVoipFreeSwitchService'; import type { IVoipService } from './types/IVoipService'; export { asyncLocalStorage } from './lib/asyncLocalStorage'; @@ -119,6 +120,7 @@ export { IUiKitCoreAppService, IVideoConfService, IVoipService, + IVoipFreeSwitchService, NPSCreatePayload, NPSVotePayload, proxifyWithWait, @@ -166,6 +168,7 @@ export const MessageReads = proxifyWithWait<IMessageReadsService>('message-reads export const Room = proxifyWithWait<IRoomService>('room'); export const Media = proxifyWithWait<IMediaService>('media'); export const VoipAsterisk = proxifyWithWait<IVoipService>('voip-asterisk'); +export const VoipFreeSwitch = proxifyWithWait<IVoipFreeSwitchService>('voip-freeswitch'); export const LivechatVoip = proxifyWithWait<IOmnichannelVoipService>('omnichannel-voip'); export const Analytics = proxifyWithWait<IAnalyticsService>('analytics'); export const LDAP = proxifyWithWait<ILDAPService>('ldap'); diff --git a/packages/core-services/src/types/IVoipFreeSwitchService.ts b/packages/core-services/src/types/IVoipFreeSwitchService.ts new file mode 100644 index 0000000000000000000000000000000000000000..575cdb157969de1b2f1b4daf1bd61a3921899cf3 --- /dev/null +++ b/packages/core-services/src/types/IVoipFreeSwitchService.ts @@ -0,0 +1,8 @@ +import type { FreeSwitchExtension } from '@rocket.chat/core-typings'; + +export interface IVoipFreeSwitchService { + getExtensionList(): Promise<FreeSwitchExtension[]>; + getExtensionDetails(requestParams: { extension: string; group?: string }): Promise<FreeSwitchExtension>; + getUserPassword(user: string): Promise<string>; + getDomain(): Promise<string>; +} diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index d6854bef72436f7b768579a1a481d42af29f5397..76d31704bc6edc0648942aa130d765807dfa1fbc 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -189,6 +189,7 @@ export interface IUser extends IRocketChatRecord { defaultRoom?: string; ldap?: boolean; extension?: string; + freeSwitchExtension?: string; inviteToken?: string; canViewAllInfo?: boolean; phone?: string; diff --git a/packages/core-typings/src/voip/FreeSwitchExtension.ts b/packages/core-typings/src/voip/FreeSwitchExtension.ts new file mode 100644 index 0000000000000000000000000000000000000000..66e40f57fb6664dfea75c8d9991ac0031affee52 --- /dev/null +++ b/packages/core-typings/src/voip/FreeSwitchExtension.ts @@ -0,0 +1,11 @@ +export type FreeSwitchExtension = { + extension: string; + context?: string; + domain?: string; + groups: string[]; + status: 'UNKNOWN' | 'REGISTERED' | 'UNREGISTERED'; + contact?: string; + callGroup?: string; + callerName?: string; + callerNumber?: string; +}; diff --git a/packages/core-typings/src/voip/index.ts b/packages/core-typings/src/voip/index.ts index 49aa0d367a8e4c860598ee3619cb7c56e457699d..0a83a01d70bc85bfea56c1910fa928732c7d3fed 100644 --- a/packages/core-typings/src/voip/index.ts +++ b/packages/core-typings/src/voip/index.ts @@ -1,5 +1,6 @@ export * from './CallStates'; export * from './ConnectionState'; +export * from './FreeSwitchExtension'; export * from './ICallerInfo'; export * from './IConnectionDelegate'; export * from './IEvents'; diff --git a/packages/freeswitch/.eslintrc.json b/packages/freeswitch/.eslintrc.json new file mode 100644 index 0000000000000000000000000000000000000000..a83aeda48e66db855445575a967f7331cf55e85d --- /dev/null +++ b/packages/freeswitch/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": ["@rocket.chat/eslint-config"], + "ignorePatterns": ["**/dist"] +} diff --git a/packages/freeswitch/jest.config.ts b/packages/freeswitch/jest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..c18c8ae02465cb695d6431de0682619d7f47f03a --- /dev/null +++ b/packages/freeswitch/jest.config.ts @@ -0,0 +1,6 @@ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { + preset: server.preset, +} satisfies Config; diff --git a/packages/freeswitch/package.json b/packages/freeswitch/package.json new file mode 100644 index 0000000000000000000000000000000000000000..aed92472c4bae3c18560af5fb45f01d56a485901 --- /dev/null +++ b/packages/freeswitch/package.json @@ -0,0 +1,30 @@ +{ + "name": "@rocket.chat/freeswitch", + "version": "0.0.1", + "private": true, + "devDependencies": { + "@rocket.chat/jest-presets": "workspace:~", + "@types/jest": "~29.5.12", + "eslint": "~8.45.0", + "jest": "~29.7.0", + "typescript": "~5.3.3" + }, + "scripts": { + "lint": "eslint --ext .js,.jsx,.ts,.tsx .", + "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", + "test": "jest", + "build": "rm -rf dist && tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" + }, + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "/dist" + ], + "dependencies": { + "@rocket.chat/core-typings": "workspace:^", + "@rocket.chat/logger": "workspace:^", + "@rocket.chat/tools": "workspace:^", + "esl": "github:pierre-lehnen-rc/esl" + } +} diff --git a/packages/freeswitch/src/FreeSwitchOptions.ts b/packages/freeswitch/src/FreeSwitchOptions.ts new file mode 100644 index 0000000000000000000000000000000000000000..20b68f61f0eda4f19f469caa2acc49c32cf092c2 --- /dev/null +++ b/packages/freeswitch/src/FreeSwitchOptions.ts @@ -0,0 +1 @@ +export type FreeSwitchOptions = { host?: string; port?: number; password?: string; timeout?: number }; diff --git a/packages/freeswitch/src/commands/getDomain.ts b/packages/freeswitch/src/commands/getDomain.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1ad0f29f38d94fa040b2960138ea90a1da5190b --- /dev/null +++ b/packages/freeswitch/src/commands/getDomain.ts @@ -0,0 +1,25 @@ +import type { StringMap } from 'esl'; + +import type { FreeSwitchOptions } from '../FreeSwitchOptions'; +import { logger } from '../logger'; +import { runCommand } from '../runCommand'; + +export function getCommandGetDomain(): string { + return 'eval ${domain}'; +} + +export function parseDomainResponse(response: StringMap): string { + const { _body: domain } = response; + + if (typeof domain !== 'string') { + logger.error({ msg: 'Failed to load user domain', response }); + throw new Error('Failed to load user domain from FreeSwitch.'); + } + + return domain; +} + +export async function getDomain(options: FreeSwitchOptions): Promise<string> { + const response = await runCommand(options, getCommandGetDomain()); + return parseDomainResponse(response); +} diff --git a/packages/freeswitch/src/commands/getExtensionDetails.ts b/packages/freeswitch/src/commands/getExtensionDetails.ts new file mode 100644 index 0000000000000000000000000000000000000000..4df2bf64a8ee9740084a8e4f1c786a4dee610bcb --- /dev/null +++ b/packages/freeswitch/src/commands/getExtensionDetails.ts @@ -0,0 +1,30 @@ +import type { FreeSwitchExtension } from '@rocket.chat/core-typings'; + +import type { FreeSwitchOptions } from '../FreeSwitchOptions'; +import { runCommand } from '../runCommand'; +import { mapUserData } from '../utils/mapUserData'; +import { parseUserList } from '../utils/parseUserList'; + +export function getCommandListFilteredUser(user: string, group = 'default'): string { + return `list_users group ${group} user ${user}`; +} + +export async function getExtensionDetails( + options: FreeSwitchOptions, + requestParams: { extension: string; group?: string }, +): Promise<FreeSwitchExtension> { + const { extension, group } = requestParams; + const response = await runCommand(options, getCommandListFilteredUser(extension, group)); + + const users = parseUserList(response); + + if (!users.length) { + throw new Error('Extension not found.'); + } + + if (users.length >= 2) { + throw new Error('Multiple extensions were found.'); + } + + return mapUserData(users[0]); +} diff --git a/packages/freeswitch/src/commands/getExtensionList.ts b/packages/freeswitch/src/commands/getExtensionList.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f5325698fc3a956d9a1dbdf329fbab8caaa346f --- /dev/null +++ b/packages/freeswitch/src/commands/getExtensionList.ts @@ -0,0 +1,17 @@ +import type { FreeSwitchExtension } from '@rocket.chat/core-typings'; + +import type { FreeSwitchOptions } from '../FreeSwitchOptions'; +import { runCommand } from '../runCommand'; +import { mapUserData } from '../utils/mapUserData'; +import { parseUserList } from '../utils/parseUserList'; + +export function getCommandListUsers(): string { + return 'list_users'; +} + +export async function getExtensionList(options: FreeSwitchOptions): Promise<FreeSwitchExtension[]> { + const response = await runCommand(options, getCommandListUsers()); + const users = parseUserList(response); + + return users.map((item) => mapUserData(item)); +} diff --git a/packages/freeswitch/src/commands/getUserPassword.ts b/packages/freeswitch/src/commands/getUserPassword.ts new file mode 100644 index 0000000000000000000000000000000000000000..5388dce6e1426852fd19b80649db1d790b0dc4c8 --- /dev/null +++ b/packages/freeswitch/src/commands/getUserPassword.ts @@ -0,0 +1,31 @@ +import type { StringMap } from 'esl'; + +import type { FreeSwitchOptions } from '../FreeSwitchOptions'; +import { logger } from '../logger'; +import { runCallback } from '../runCommand'; +import { getCommandGetDomain, parseDomainResponse } from './getDomain'; + +export function getCommandGetUserPassword(user: string, domain = 'rocket.chat'): string { + return `user_data ${user}@${domain} param password`; +} + +export function parsePasswordResponse(response: StringMap): string { + const { _body: password } = response; + + if (password === undefined) { + logger.error({ msg: 'Failed to load user password', response }); + throw new Error('Failed to load user password from FreeSwitch.'); + } + + return password; +} + +export async function getUserPassword(options: FreeSwitchOptions, user: string): Promise<string> { + return runCallback(options, async (runCommand) => { + const domainResponse = await runCommand(getCommandGetDomain()); + const domain = parseDomainResponse(domainResponse); + + const response = await runCommand(getCommandGetUserPassword(user, domain)); + return parsePasswordResponse(response); + }); +} diff --git a/packages/freeswitch/src/commands/index.ts b/packages/freeswitch/src/commands/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac33235067db33aa6cea793d8b3cc263c4f777c5 --- /dev/null +++ b/packages/freeswitch/src/commands/index.ts @@ -0,0 +1,4 @@ +export * from './getDomain'; +export * from './getExtensionDetails'; +export * from './getExtensionList'; +export * from './getUserPassword'; diff --git a/packages/freeswitch/src/connect.ts b/packages/freeswitch/src/connect.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ea3741edc42350fa1dd24c4ecc0f927fec4ec9e --- /dev/null +++ b/packages/freeswitch/src/connect.ts @@ -0,0 +1,82 @@ +import { Socket, type SocketConnectOpts } from 'node:net'; + +import { FreeSwitchResponse } from 'esl'; + +import { logger } from './logger'; + +const defaultPassword = 'ClueCon'; + +export async function connect(options?: { host?: string; port?: number; password?: string }): Promise<FreeSwitchResponse> { + const host = options?.host ?? '127.0.0.1'; + const port = options?.port ?? 8021; + const password = options?.password ?? defaultPassword; + + return new Promise((resolve, reject) => { + logger.debug({ msg: 'FreeSwitchClient::connect', options: { host, port } }); + + const socket = new Socket(); + const currentCall = new FreeSwitchResponse(socket, logger); + let connecting = true; + + socket.once('connect', () => { + void (async (): Promise<void> => { + connecting = false; + try { + // Normally when the client connects, FreeSwitch will first send us an authentication request. We use it to trigger the remainder of the stack. + await currentCall.onceAsync('freeswitch_auth_request', 20_000, 'FreeSwitchClient expected authentication request'); + await currentCall.auth(password); + currentCall.auto_cleanup(); + await currentCall.event_json('CHANNEL_EXECUTE_COMPLETE', 'BACKGROUND_JOB'); + } catch (error) { + logger.error('FreeSwitchClient: connect error', error); + reject(error); + } + + if (currentCall) { + resolve(currentCall); + } + })(); + }); + + socket.once('error', (error) => { + if (!connecting) { + return; + } + + logger.error({ msg: 'failed to connect to freeswitch server', error }); + connecting = false; + reject(error); + }); + + socket.once('end', () => { + if (!connecting) { + return; + } + + logger.debug('FreeSwitchClient::connect: client received `end` event (remote end sent a FIN packet)'); + connecting = false; + reject(new Error('connection-ended')); + }); + + socket.on('warning', (data) => { + if (!connecting) { + return; + } + + logger.warn({ msg: 'FreeSwitchClient: warning', data }); + }); + + try { + logger.debug('FreeSwitchClient::connect: socket.connect', { options: { host, port } }); + socket.connect({ + host, + port, + password, + } as unknown as SocketConnectOpts); + } catch (error) { + logger.error('FreeSwitchClient::connect: socket.connect error', { error }); + connecting = false; + reject(error); + } + }); +} diff --git a/packages/freeswitch/src/getCommandResponse.ts b/packages/freeswitch/src/getCommandResponse.ts new file mode 100644 index 0000000000000000000000000000000000000000..b71618b37ec27976781f2c39d844caeb3c5beb74 --- /dev/null +++ b/packages/freeswitch/src/getCommandResponse.ts @@ -0,0 +1,12 @@ +import type { FreeSwitchEventData, StringMap } from 'esl'; + +import { logger } from './logger'; + +export async function getCommandResponse(response: FreeSwitchEventData, command?: string): Promise<StringMap> { + if (!response?.body) { + logger.error('No response from FreeSwitch server', command, response); + throw new Error('No response from FreeSwitch server.'); + } + + return response.body; +} diff --git a/packages/freeswitch/src/index.ts b/packages/freeswitch/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..30272ff42df99ff339b4cea2dbb6a8e9f1c6efa7 --- /dev/null +++ b/packages/freeswitch/src/index.ts @@ -0,0 +1 @@ +export * from './commands'; diff --git a/packages/freeswitch/src/logger.ts b/packages/freeswitch/src/logger.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c025069d8ad44a5e42c9a3002d14324d4627d28 --- /dev/null +++ b/packages/freeswitch/src/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('FreeSwitch'); diff --git a/packages/freeswitch/src/runCommand.ts b/packages/freeswitch/src/runCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..25b87a4d2b803b033f9868e3c3682b6468f9502d --- /dev/null +++ b/packages/freeswitch/src/runCommand.ts @@ -0,0 +1,30 @@ +import { wrapExceptions } from '@rocket.chat/tools'; +import { FreeSwitchResponse, type StringMap } from 'esl'; + +import type { FreeSwitchOptions } from './FreeSwitchOptions'; +import { connect } from './connect'; +import { getCommandResponse } from './getCommandResponse'; + +export async function runCallback<T>( + options: FreeSwitchOptions, + cb: (runCommand: (command: string) => Promise<StringMap>) => Promise<T>, +): Promise<T> { + const { host, port, password, timeout } = options; + + const call = await connect({ host, port, password }); + try { + // Await result so it runs within the try..finally scope + const result = await cb(async (command) => { + const response = await call.bgapi(command, timeout ?? FreeSwitchResponse.default_command_timeout); + return getCommandResponse(response, command); + }); + + return result; + } finally { + await wrapExceptions(async () => call.end()).suppress(); + } +} + +export async function runCommand(options: FreeSwitchOptions, command: string): Promise<StringMap> { + return runCallback(options, async (runCommand) => runCommand(command)); +} diff --git a/packages/freeswitch/src/utils/mapUserData.ts b/packages/freeswitch/src/utils/mapUserData.ts new file mode 100644 index 0000000000000000000000000000000000000000..265cf1f6946ebe6a158ca7a247e219784453c63e --- /dev/null +++ b/packages/freeswitch/src/utils/mapUserData.ts @@ -0,0 +1,33 @@ +import type { FreeSwitchExtension } from '@rocket.chat/core-typings'; +import type { StringMap } from 'esl'; + +import { parseUserStatus } from './parseUserStatus'; + +export function mapUserData(user: StringMap): FreeSwitchExtension { + const { + userid: extension, + context, + domain, + groups, + contact, + callgroup: callGroup, + effective_caller_id_name: callerName, + effective_caller_id_number: callerNumber, + } = user; + + if (!extension) { + throw new Error('Invalid user identification.'); + } + + return { + extension, + context, + domain, + groups: groups?.split('|') || [], + status: parseUserStatus(contact), + contact, + callGroup, + callerName, + callerNumber, + }; +} diff --git a/packages/freeswitch/src/utils/parseUserList.ts b/packages/freeswitch/src/utils/parseUserList.ts new file mode 100644 index 0000000000000000000000000000000000000000..21ed42ef1ac20477dd06aedf37570670d4279ff0 --- /dev/null +++ b/packages/freeswitch/src/utils/parseUserList.ts @@ -0,0 +1,54 @@ +import type { StringMap } from 'esl'; + +export function parseUserList(commandResponse: StringMap): Record<string, string>[] { + const { _body: text } = commandResponse; + + if (!text || typeof text !== 'string') { + throw new Error('Invalid response from FreeSwitch server.'); + } + + const lines = text.split('\n'); + const columnsLine = lines.shift(); + if (!columnsLine) { + throw new Error('Invalid response from FreeSwitch server.'); + } + + const columns = columnsLine.split('|'); + + const users = new Map<string, Record<string, string | string[]>>(); + + for (const line of lines) { + const values = line.split('|'); + if (!values.length || !values[0]) { + continue; + } + const user = Object.fromEntries( + values.map((value, index) => { + return [(columns.length > index && columns[index]) || `column${index}`, value]; + }), + ); + + if (!user.userid || user.userid === '+OK') { + continue; + } + + const { group, ...newUserData } = user; + + const existingUser = users.get(user.userid); + const groups = (existingUser?.groups || []) as string[]; + + if (group && !groups.includes(group)) { + groups.push(group); + } + + users.set(user.userid, { + ...(users.get(user.userid) || newUserData), + groups, + }); + } + + return [...users.values()].map((user) => ({ + ...user, + groups: (user.groups as string[]).join('|'), + })); +} diff --git a/packages/freeswitch/src/utils/parseUserStatus.ts b/packages/freeswitch/src/utils/parseUserStatus.ts new file mode 100644 index 0000000000000000000000000000000000000000..48cb9b32474a0f3c49f90a0f0dac75005b60b797 --- /dev/null +++ b/packages/freeswitch/src/utils/parseUserStatus.ts @@ -0,0 +1,17 @@ +import type { FreeSwitchExtension } from '@rocket.chat/core-typings'; + +export function parseUserStatus(status: string | undefined): FreeSwitchExtension['status'] { + if (!status) { + return 'UNKNOWN'; + } + + if (status === 'error/user_not_registered') { + return 'UNREGISTERED'; + } + + if (status.startsWith('sofia/')) { + return 'REGISTERED'; + } + + return 'UNKNOWN'; +} diff --git a/packages/freeswitch/tests/mapUserData.test.ts b/packages/freeswitch/tests/mapUserData.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..44b55db492c21cb2faa98adfbc92daf417ca4c5f --- /dev/null +++ b/packages/freeswitch/tests/mapUserData.test.ts @@ -0,0 +1,37 @@ +import { mapUserData } from '../src/utils/mapUserData'; + +expect(() => mapUserData(undefined as unknown as any)).toThrow(); +expect(() => mapUserData({ extension: '15' })).toThrow('Invalid user identification.'); + +test.each([ + [{ userid: '1' }, { extension: '1', groups: [], status: 'UNKNOWN' }], + [ + { userid: '1', context: 'default' }, + { extension: '1', context: 'default', groups: [], status: 'UNKNOWN' }, + ], + [ + { + userid: '1', + context: 'default', + domain: 'domainName', + groups: 'default|employee', + contact: 'no', + callgroup: 'call group', + effective_caller_id_name: 'caller_id_name', + effective_caller_id_number: 'caller_id_number', + }, + { + extension: '1', + context: 'default', + domain: 'domainName', + groups: ['default', 'employee'], + contact: 'no', + callGroup: 'call group', + callerName: 'caller_id_name', + callerNumber: 'caller_id_number', + status: 'UNKNOWN', + }, + ], +])('parse user status: %p', (input, output) => { + expect(mapUserData(input)).toMatchObject(output); +}); diff --git a/packages/freeswitch/tests/parseUserList.test.ts b/packages/freeswitch/tests/parseUserList.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1db317f24faa9c7d1e248dbb80357d0299fa26d --- /dev/null +++ b/packages/freeswitch/tests/parseUserList.test.ts @@ -0,0 +1,214 @@ +import { parseUserList } from '../src/utils/parseUserList'; +import { makeFreeSwitchResponse } from './utils/makeFreeSwitchResponse'; + +test.each(['', undefined, 200 as unknown as any, '\nsomething'])('Invalid FreeSwitch responses', (input) => { + expect(() => parseUserList({ _body: input })).toThrow('Invalid response from FreeSwitch server.'); +}); + +test('Should return an empty list when there is no userid column', () => { + expect( + parseUserList( + makeFreeSwitchResponse([ + ['aaa', 'bbb', 'ccc'], + ['AData', 'BData', 'CData'], + ['AData2', 'BData2', 'CData2'], + ['AData3', 'BData3', 'CData3'], + ]), + ), + ).toMatchObject([]); +}); + +test('Should return an empty list when there is no lowercase userid column', () => { + expect( + parseUserList( + makeFreeSwitchResponse([ + ['aaa', 'bbb', 'ccc', 'USERID'], + [], + ['AData', 'BData', 'CData', '15'], + ['AData2', 'BData2', 'CData2', '20'], + ['AData3', 'BData3', 'CData3', '30'], + ]), + ), + ).toMatchObject([]); +}); + +test(`Should return an empty list when all records' userid is either missing or equal to +OK`, () => { + expect( + parseUserList( + makeFreeSwitchResponse([ + ['aaa', 'bbb', 'ccc', 'userid'], + ['AData', 'BData', 'CData'], + ['AData2', 'BData2', 'CData2', ''], + ['AData3', 'BData3', 'CData3', '+OK'], + ]), + ), + ).toMatchObject([]); +}); + +test.each([ + [ + [['userid'], ['1']], + [ + { + userid: '1', + }, + ], + ], + [ + [['userid'], ['1'], ['2'], ['3']], + [ + { + userid: '1', + }, + { + userid: '2', + }, + { + userid: '3', + }, + ], + ], + [ + [ + ['userid', 'group', 'name'], + ['1', 'default', 'User 1'], + ['2', 'default', 'User 2'], + ['3', 'default', 'User 3'], + ], + [ + { + userid: '1', + groups: 'default', + name: 'User 1', + }, + { + userid: '2', + groups: 'default', + name: 'User 2', + }, + { + userid: '3', + groups: 'default', + name: 'User 3', + }, + ], + ], + [ + [ + ['userid', 'name'], + ['1', 'User 1', 'AnotherValue'], + ['2', 'User 2'], + ['3', 'User 3'], + ], + [ + { + userid: '1', + name: 'User 1', + column2: 'AnotherValue', + }, + { + userid: '2', + name: 'User 2', + }, + { + userid: '3', + name: 'User 3', + }, + ], + ], +])('parse valid user list: %p', (input, output) => { + expect(parseUserList(makeFreeSwitchResponse(input))).toMatchObject(output); +}); + +test.each([ + [ + [['userid'], ['1'], ['1']], + [ + { + userid: '1', + }, + ], + ], + [ + [['userid'], ['1'], ['2'], ['1'], ['2'], ['3'], ['3']], + [ + { + userid: '1', + }, + { + userid: '2', + }, + { + userid: '3', + }, + ], + ], + [ + [ + ['userid', 'group'], + ['1', 'default'], + ['1', 'employee'], + ], + [ + { + userid: '1', + groups: 'default|employee', + }, + ], + ], + [ + // When there's multiple records for the same user, join the group names from all of them into the data from the first record + [ + ['userid', 'group'], + ['1', 'default'], + ['2', 'default'], + ['1', 'employee'], + ['2', 'manager'], + ['3', 'default'], + ['3', 'owner'], + ], + [ + { + userid: '1', + groups: 'default|employee', + }, + { + userid: '2', + groups: 'default|manager', + }, + { + userid: '3', + groups: 'default|owner', + }, + ], + ], + [ + // When there's multiple records for the same user without group names, use only the data from the first of them + [ + ['userid', 'something_else'], + ['1', '1.1'], + ['1', '1.2'], + ['2', '2.1'], + ['2', '2.2', 'extra_value'], + ['3', ''], + ['3', '3.2'], + ['3', '3.3'], + ], + [ + { + userid: '1', + something_else: '1.1', + }, + { + userid: '2', + something_else: '2.1', + }, + { + userid: '3', + something_else: '', + }, + ], + ], +])('parse user list with duplicate userids: %p', (input, output) => { + expect(parseUserList(makeFreeSwitchResponse(input))).toMatchObject(output); +}); diff --git a/packages/freeswitch/tests/parseUserStatus.test.ts b/packages/freeswitch/tests/parseUserStatus.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..73e2f0599097c9bc5427bd4c74e890b36c397f05 --- /dev/null +++ b/packages/freeswitch/tests/parseUserStatus.test.ts @@ -0,0 +1,14 @@ +import { parseUserStatus } from '../src/utils/parseUserStatus'; + +test.each([ + ['', 'UNKNOWN'], + [undefined, 'UNKNOWN'], + ['error/user_not_registered', 'UNREGISTERED'], + ['ERROR/user_not_registered', 'UNKNOWN'], + ['sofia/user_data', 'REGISTERED'], + ['sofia/', 'REGISTERED'], + ['SOFIA/', 'UNKNOWN'], + ['luana/', 'UNKNOWN'], +])('parse user status: %p', (input, output) => { + expect(parseUserStatus(input)).toBe(output); +}); diff --git a/packages/freeswitch/tests/utils/makeFreeSwitchResponse.ts b/packages/freeswitch/tests/utils/makeFreeSwitchResponse.ts new file mode 100644 index 0000000000000000000000000000000000000000..58c2da59a1feca37e9adcbd94e8fffa623661318 --- /dev/null +++ b/packages/freeswitch/tests/utils/makeFreeSwitchResponse.ts @@ -0,0 +1,5 @@ +import type { StringMap } from 'esl'; + +export const makeFreeSwitchResponse = (lines: string[][]): StringMap => ({ + _body: lines.map((columns) => columns.join('|')).join('\n'), +}); diff --git a/packages/freeswitch/tsconfig.json b/packages/freeswitch/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..d2a7e3ee7c8b8c116eb58c173a2d8b7bb226aaf0 --- /dev/null +++ b/packages/freeswitch/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.server.json", + "compilerOptions": { + "declaration": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src/**/*"], +} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 47d2034e002e6bcd5b878a3c251e9f919a8ef2a1..ba7ed6ec627ae50d07affed20fff212aadf94f6f 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -697,6 +697,7 @@ "Assets_Description": "Modify your workspace's logo, icon, favicon and more.", "Asset_preview": "Asset preview", "Assign_admin": "Assigning admin", + "Assign_extension": "Assign extension", "Assign_new_conversations_to_bot_agent": "Assign new conversations to bot agent", "Assign_new_conversations_to_bot_agent_description": "The routing system will attempt to find a bot agent before addressing new conversations to a human agent.", "assign-admin-role": "Assign Admin Role", @@ -706,6 +707,8 @@ "Associate": "Associate", "Associate_Agent": "Associate Agent", "Associate_Agent_to_Extension": "Associate Agent to Extension", + "Associate_Extension": "Associate Extension", + "Associate_User_to_Extension": "Associate User to Extension", "at": "at", "At_least_one_added_token_is_required_by_the_user": "At least one added token is required by the user", "AtlassianCrowd": "Atlassian Crowd", @@ -903,6 +906,8 @@ "Call_started": "Call started", "Call_unavailable_for_federation": "Call is unavailable for Federated rooms", "Call_was_not_answered": "Call was not answered", + "Call_transfered_to__name__": "Call transfered to {{name}}", + "Call_terminated": "Call terminated", "Caller": "Caller", "Caller_Id": "Caller ID", "Camera_access_not_allowed": "Camera access was not allowed, please check your browser settings.", @@ -1053,6 +1058,7 @@ "close": "close", "Close": "Close", "Close_chat": "Close chat", + "Close_Dialpad": "Close Dialpad", "Close_room_description": "You are about to close this chat. Are you sure you want to continue?", "close-livechat-room": "Close Omnichannel Room", "close-livechat-room_description": "Permission to close the current Omnichannel room", @@ -1205,6 +1211,7 @@ "Copied": "Copied", "Copy": "Copy", "Copy_text": "Copy text", + "Copy_phone_number": "Copy phone number", "Copy_to_clipboard": "Copy to clipboard", "COPY_TO_CLIPBOARD": "COPY TO CLIPBOARD", "could-not-access-webdav": "Could not access WebDAV", @@ -1672,6 +1679,7 @@ "Devices": "Devices", "Devices_Set": "Devices Set", "Device_settings": "Device Settings", + "Device_settings_not_supported_by_browser": "Device settings (not supported by the browser)", "Dialed_number_doesnt_exist": "Dialed number doesn't exist", "Dialed_number_is_incomplete": "Dialed number is not complete", "Different_Style_For_User_Mentions": "Different style for user mentions", @@ -1715,6 +1723,7 @@ "Disable_two-factor_authentication": "Disable two-factor authentication via TOTP", "Disable_two-factor_authentication_email": "Disable two-factor authentication via Email", "Disabled": "Disabled", + "Disable_voice_calling": "Disable voice calling", "Disallow_reacting": "Disallow Reacting", "Disallow_reacting_Description": "Disallows reacting", "Discard": "Discard", @@ -1956,6 +1965,7 @@ "Enable_two-factor_authentication": "Enable two-factor authentication via TOTP", "Enable_two-factor_authentication_email": "Enable two-factor authentication via Email", "Enable_unlimited_apps": "Enable unlimited apps", + "Enable_voice_calling": "Enable voice calling", "Enabled": "Enabled", "Encrypted": "Encrypted", "Encrypted_channel_Description": "Messages are end-to-end encrypted, search will not work and notifications may not show message content", @@ -2250,6 +2260,8 @@ "Export_My_Data": "Export My Data (JSON)", "expression": "Expression", "Extended": "Extended", + "Extension": "Extension", + "Extension_removed": "Extension removed", "Extensions": "Extensions", "Extension_Number": "Extension Number", "Extension_Status": "Extension Status", @@ -2276,6 +2288,8 @@ "Failed_To_Start_Import": "Failed to start import operation", "Failed_To_upload_Import_File": "Failed to upload import file", "Failed_to_validate_invite_token": "Failed to validate invite token", + "Failed_to_copy_phone_number": "Failed to copy phone number", + "Failed_to_transfer_call": "Failed to transfer call", "Failure": "Failure", "False": "False", "Fallback_forward_department": "Fallback department for forwarding", @@ -2578,6 +2592,7 @@ "Grouping": "Grouping", "Guest": "Guest", "Hash": "Hash", + "Hang_up_and_transfer_call": "Hang up and transfer call", "Header": "Header", "Header_and_Footer": "Header and Footer", "Pharmaceutical": "Pharmaceutical", @@ -2725,6 +2740,8 @@ "Include_Offline_Agents": "Include offline agents", "Inclusive": "Inclusive", "Incoming": "Incoming", + "Incoming_call": "Incoming call", + "Incoming_call_transfer": "Incoming call transfer", "Incoming_call_from": "Incoming call from", "Incoming_Livechats": "Queued chats", "Incoming_WebHook": "Incoming WebHook", @@ -3397,6 +3414,7 @@ "logout-other-user_description": "Permission to logout other users", "Logs": "Logs", "Logs_Description": "Configure how server logs are received.", + "Long_press_to_do_x": "Long press to do {{action}}", "Longest_chat_duration": "Longest Chat Duration", "Longest_reaction_time": "Longest Reaction Time", "Longest_response_time": "Longest Response Time", @@ -3474,6 +3492,8 @@ "manage-user-status_description": "Permission to manage the server custom user statuses", "manage-voip-call-settings": "Manage Voip Call Settings", "manage-voip-call-settings_description": "Permission to manage voip call settings", + "manage-voip-extensions": "Manage Voip Extensions", + "manage-voip-extensions_description": "Permission to manage voip extensions assigned to users", "manage-voip-contact-center-settings": "Manage Voip Contact Center Settings", "manage-voip-contact-center-settings_description": "Permission to manage voip contact center settings", "Manage_Omnichannel": "Manage Omnichannel", @@ -4204,6 +4224,7 @@ "Thank_You_For_Choosing_RocketChat": "Thank you for choosing Rocket.Chat!", "Phone_already_exists": "Phone already exists", "Phone_number": "Phone number", + "Phone_number_copied": "Phone number copied", "PID": "PID", "Pin": "Pin", "Pin_Message": "Pin Message", @@ -4438,6 +4459,7 @@ "Registration_status": "Registration status", "Registration_Succeeded": "Registration Succeeded", "Registration_via_Admin": "Registration via Admin", + "Registration_information_not_found": "Registration information not found", "Regular_Expressions": "Regular Expressions", "Reject_call": "Reject call", "Release": "Release", @@ -4459,6 +4481,8 @@ "Remove_custom_oauth": "Remove custom OAuth", "Remove_from_room": "Remove from room", "Remove_from_team": "Remove from team", + "Remove_extension": "Remove extension", + "Remove_last_character": "Remove last character", "Remove_last_admin": "Removing last admin", "Remove_someone_from_room": "Remove someone from the room", "remove-closed-livechat-room": "Remove Closed Omnichannel Room", @@ -4845,6 +4869,7 @@ "Select_user": "Select user", "Select_users": "Select users", "Select_period": "Select period", + "Select_someone_to_transfer_the_call_to": "Select someone to transfer the call to", "Selected_agents": "Selected agents", "Selected_by_default": "Selected by default", "Selected_departments": "Selected Departments", @@ -5080,6 +5105,7 @@ "Sound File": "Sound File", "Source": "Source", "Speakers": "Speakers", + "Speaker": "Speaker", "spy-voip-calls": "Spy Voip Calls", "spy-voip-calls_description": "Permission to spy voip calls", "SSL": "SSL", @@ -5266,6 +5292,7 @@ "Teams_Select_a_team": "Select a team", "Teams_Search_teams": "Search Teams", "Teams_New_Read_only_Label": "Read-only", + "Temporarily_unavailable": "Temporarily unavailable", "Technology_Services": "Technology Services", "Upgrade_tab_connection_error_description": "Looks like you have no internet connection. This may be because your workspace is installed on a fully-secured air-gapped server", "Terms": "Terms", @@ -5462,6 +5489,8 @@ "onboarding.form.registeredServerForm.continueStandalone": "Continue as standalone", "transfer-livechat-guest": "Transfer Livechat Guests", "transfer-livechat-guest_description": "Permission to transfer livechat guests", + "Transfer_to": "Transfer to", + "Transfer_call": "Transfer call", "Transferred": "Transferred", "Translate": "Translate", "Translated": "Translated", @@ -5535,10 +5564,13 @@ "UI_Use_Real_Name": "Use Real Name", "unable-to-get-file": "Unable to get file", "Unable_to_load_active_connections": "Unable to load active connections", + "Unable_to_complete_call": "Unable to complete call", + "Unable_to_make_calls_while_another_is_ongoing": "Unable to make calls while another call is ongoing", "Unarchive": "Unarchive", "unarchive-room": "Unarchive Room", "unarchive-room_description": "Permission to unarchive channels", "Unassigned": "Unassigned", + "Unassign_extension": "Unassign extension", "unauthorized": "Not authorized", "Unavailable": "Unavailable", "Unavailable_in_encrypted_channels": "Unavailable in encrypted channels", @@ -5696,6 +5728,7 @@ "User_uploaded_a_file_to_you": "<strong>{{username}}</strong> sent you a file", "User_uploaded_file": "Uploaded a file", "User_uploaded_image": "Uploaded an image", + "User_extension_not_found": "User extension not found", "user-generate-access-token": "User Generate Access Token", "user-generate-access-token_description": "Permission for users to generate access tokens", "UserData_EnableDownload": "Enable User Data Download", @@ -5739,6 +5772,7 @@ "Users_key_has_been_reset": "User's key has been reset", "Users_reacted": "Users that Reacted", "Users_TOTP_has_been_reset": "User's TOTP has been reset", + "User_Without_Extensions": "Users without extensions", "Uses": "Uses", "Uses_left": "Uses left", "UTC_Timezone": "UTC Timezone", @@ -5772,6 +5806,7 @@ "Video_Chat_Window": "Video Chat", "Video_Conference": "Conference Call", "Video_Call_unavailable_for_this_type_of_room": "Video Call is unavailable for this type of room", + "Video_call": "Video call", "Video_Conferences": "Conference Calls", "Video_Conference_Info": "Meeting Information", "Video_Conference_Url": "Meeting URL", @@ -5912,6 +5947,10 @@ "view-statistics_description": "Permission to view system statistics such as number of users logged in, number of rooms, operating system information", "view-user-administration": "View User Administration", "view-user-administration_description": "Permission to partial, read-only list view of other user accounts currently logged into the system. No user account information is accessible with this permission", + "view-user-voip-extension": "View User VoIP Extension", + "view-user-voip-extension_description": "Permission to view user's assigned VoIP Extension", + "view-voip-extension-details": "View VoIP Extension Details", + "view-voip-extension-details_description": "Permission to view the details associated with VoIP extensions", "Viewing_room_administration": "Viewing room administration", "Visibility": "Visibility", "Visible": "Visible", @@ -5929,6 +5968,12 @@ "Visitor_page_URL": "Visitor page URL", "Visitor_time_on_site": "Visitor time on site", "Voice_Call": "Voice Call", + "Voice_call": "Voice call", + "Voice_call_extension": "Voice call extension", + "Voice_calling_disabled": "Voice calling is disabled", + "Voice_calling_enabled": "Voice calling is enabled", + "Voice_calling_registration_failed": "Voice calling registration failed", + "Voice_Call_Extension": "Voice Call Extension", "VoIP_Enable_Keep_Alive_For_Unstable_Networks": "Enable SIP Options Keep Alive", "VoIP_Enable_Keep_Alive_For_Unstable_Networks_Description": "Monitor the status of multiple external SIP gateways by sending periodic SIP OPTIONS messages. Used for unstable networks.", "VoIP_Enabled": "Enable voice channel", @@ -5958,6 +6003,13 @@ "VoIP_JWT_Secret_description": "Set a secret key for sharing extension details from server to client as JWT instead of plain text. Extension registration details will be sent as plain text if a secret key has not been set.", "Voip_is_disabled": "VoIP is disabled", "Voip_is_disabled_description": "To view the list of extensions it is necessary to activate VoIP, do so in the Settings tab.", + "VoIP_TeamCollab": "VoIP for Team Collaboration", + "VoIP_TeamCollab_Enabled": "Enabled", + "VoIP_TeamCollab_FreeSwitch_Host": "FreeSwitch Host", + "VoIP_TeamCollab_FreeSwitch_Port": "FreeSwitch Port", + "VoIP_TeamCollab_FreeSwitch_Password": "FreeSwitch Password", + "VoIP_TeamCollab_FreeSwitch_Timeout": "FreeSwitch Request Timeout", + "VoIP_TeamCollab_FreeSwitch_WebSocket_Path": "WebSocket Path", "VoIP_Toggle": "Enable/Disable VoIP", "Chat_opened_by_visitor": "Chat opened by the visitor", "Wait_activation_warning": "Before you can login, your account must be manually activated by an administrator.", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index b2575a19067180b07018325e3666446c6c919cee..cf06ddc01a89d305580b1e55a4d0ae77d731f18b 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -598,6 +598,7 @@ "Are_you_sure_you_want_to_reset_the_name_of_all_priorities": "Tem certeza que deseja redefinir o nome de todas as prioridades?", "Assets": "Recursos", "Assign_admin": "Atribuindo administrador", + "Assign_extension": "Atribuir extensão", "Assign_new_conversations_to_bot_agent": "Atribuir novas conversas para um agente bot", "Assign_new_conversations_to_bot_agent_description": "O sistema de roteamento vai procurar um agente bot antes de encaminhar novas conversas para um agente humano.", "assign-admin-role": "Atribuir função de administrador", @@ -760,6 +761,8 @@ "Call_Information": "Informação da chamada", "Call_provider": "Provedor de chamada", "Call_Already_Ended": "Chamada já encerrada", + "Call_transfered_to__name__": "Chamada transferida para {{name}}", + "Call_terminated": "Chamada encerrada", "call-management": "Gestão de chamadas", "call-management_description": "Permissão para iniciar reunião", "Caller": "Autor da chamada", @@ -888,6 +891,7 @@ "close": "fechar", "Close": "Fechar", "Close_chat": "Fechar conversa", + "Close_Dialpad": "Fechat Discador", "Close_room_description": "Você está prestes a fechar esta conversa. Você tem certeza de que deseja continuar?", "close-livechat-room": "Fechar sala omnichannel", "close-livechat-room_description": "Permissão para fechar a sala Omnichannel atual", @@ -1011,6 +1015,7 @@ "Copied": "Copiado", "Copy": "Copiar", "Copy_text": "Copiar texto", + "Copy_phone_number": "Copiar número de telefone", "Copy_to_clipboard": "Copiar para área de transferência", "COPY_TO_CLIPBOARD": "COPIAR PARA ÃREA DE TRANSFERÊNCIA", "could-not-access-webdav": "Não foi possÃvel acessar o WebDAV", @@ -1423,6 +1428,8 @@ "Device_Changes_Not_Available_Insecure_Context": "Mudanças de dispositivo somente estão disponÃveis em contextos seguros. (https://)", "line": "linha", "Device_Management_IP": "IP", + "Device_settings": "Configurações de dispositivo", + "Device_settings_not_supported_by_browser": "Configurações de dispositivo (não suportado pelo browser)", "Different_Style_For_User_Mentions": "Estilo diferente para as menções do usuário", "Livechat_Facebook_API_Key": "Chave da API OmniChannel", "Livechat_Facebook_API_Secret": "Secret da API OmniChannel", @@ -1457,6 +1464,7 @@ "Disable_Notifications": "Desativar as notificações", "Disable_two-factor_authentication": "Desativar a autenticação de dois fatores por TOTP", "Disable_two-factor_authentication_email": "Desativar a autenticação de dois fatores por e-mail", + "Disable_voice_calling": "Desabilitar chamadas de voz", "Disabled": "Desabilitado", "Disallow_reacting": "Não permitir reagir", "Disallow_reacting_Description": "Não permite reagir", @@ -1641,6 +1649,7 @@ "Enable_Svg_Favicon": "Ativar favicon SVG", "Enable_two-factor_authentication": "Ativar autenticação de dois fatores por TOTP", "Enable_two-factor_authentication_email": "Ativar autenticação de dois fatores por e-mail", + "Enable_voice_calling": "Habilitar chamadas de voz", "Enabled": "Ativado", "Encrypted": "Criptografado", "Encrypted_channel_Description": "Canal criptografado de ponta a ponta. A pesquisa não funcionará com canais criptografados e as notificações podem não mostrar o conteúdo das mensagens.", @@ -1869,6 +1878,7 @@ "Export_My_Data": "Exportar meus dados (JSON)", "expression": "Expressão", "Extended": "Estendido", + "Extension": "Extensão", "Extensions": "Extensões", "Extension_Number": "Número de extensão", "Extension_Status": "Status da extensão", @@ -1894,6 +1904,8 @@ "Failed_To_Load_Import_Operation": "Falha ao carregar operação de importação", "Failed_To_Start_Import": "Falha ao iniciar operação de importação", "Failed_to_validate_invite_token": "Falha na validação do token de convite", + "Failed_to_copy_phone_number": "Falha ao copiar número de telefone", + "Failed_to_transfer_call": "Failed to transfer call", "False": "Falso", "Fallback_forward_department": "Departamento alternativo para encaminhamento", "Fallback_forward_department_description": "Permite definir um departamento alternativo que vai receber as conversas encaminhadas a este, caso não haja agentes online no momento", @@ -2051,7 +2063,7 @@ "Forward_to_user": "Encaminhar ao usuário", "Forwarding": "Encaminhando", "Free": "Grátis", - "Free_Extension_Numbers": "Números gratuitos de extensão", + "Free_Extension_Numbers": "Números de extensão livres", "Free_Apps": "Aplicativos gratuitos", "Frequently_Used": "Usados frequentemente", "Friday": "Sexta-feira", @@ -2106,6 +2118,7 @@ "Grouping": "Agrupamento", "Guest": "Convidado", "Hash": "Hash", + "Hang_up_and_transfer_call": "Desligar e transferir a chamada", "Header": "Cabeçalho", "Header_and_Footer": "Cabeçalho e rodapé", "Pharmaceutical": "Farmacêutico", @@ -2782,6 +2795,7 @@ "Logout": "Sair", "Logout_Others": "Sair de outros locais conectados", "Logs": "Registros", + "Long_press_to_do_x": "Pressione e segure para {{action}}", "Longest_chat_duration": "Maior duração da conversa", "Longest_reaction_time": "Maior tempo de reação", "Longest_response_time": "Maior tempo de resposta", @@ -3093,6 +3107,7 @@ "New": "Novo", "New_Application": "Novo aplicativo", "New_Business_Hour": "Novo horário de expediente", + "New_Call": "Nova Chamada", "New_chat_in_queue": "Novo chat na fila", "New_chat_priority": "Prioridade alterada: {{user}} alterou a prioridade para {{priority}}", "New_chat_transfer": "Nova transferência de conversa: {{transfer}}", @@ -3172,6 +3187,7 @@ "Not_authorized": "Não autorizado", "Normal": "Normal", "Not_Available": "Não disponÃvel", + "Not_assigned": "Não atribuÃdo", "Not_enough_data": "Não há dados suficientes", "Not_following": "Não segue", "Not_Following": "Não segue", @@ -3355,6 +3371,7 @@ "Thank_you_exclamation_mark": "Obrigado!", "Phone_already_exists": "Telefone já cadastrado", "Phone_number": "Número de telefone", + "Phone_number_copied": "Número de telefone copiado", "PID": "PID", "Pin": "Fixar", "Pin_Message": "Fixar mensagem", @@ -3547,6 +3564,7 @@ "Registration": "Registro", "Registration_Succeeded": "Registrado com sucesso", "Registration_via_Admin": "Registro via admin", + "Registration_information_not_found": "Informações de registro não encontradas", "Regular_Expressions": "Expressões regulares", "Reject_call": "Rejeitar chamada", "Release": "Versão", @@ -3565,6 +3583,8 @@ "Remove_custom_oauth": "Remover oauth customizado", "Remove_from_room": "Remover da sala", "Remove_from_team": "Remover da equipe", + "Remove_extension": "Remover extensão", + "Remove_last_character": "Remover último caracter", "Remove_last_admin": "Removendo último admin", "Remove_someone_from_room": "Remover alguém da sala", "remove-closed-livechat-room": "Remover sala de omnichannel fechada", @@ -3870,6 +3890,7 @@ "Select_user": "Selecionar usuário", "Select_users": "Selecione usuários", "Selected_agents": "Agentes selecionados", + "Select_someone_to_transfer_the_call_to": "Selecione alguém para transferir a chamada", "Selected_departments": "Departamentos selecionados", "Selected_monitors": "Monitores selecionados", "Selecting_users": "Selecionando usuários", @@ -4046,6 +4067,7 @@ "Sound": "Som", "Sound_File_mp3": "Arquivo de som (mp3)", "Source": "Fonte", + "Speaker": "Alto falante", "SSL": "SSL", "Star": "Favoritar", "Star_Message": "Favoritar mensagem", @@ -4208,6 +4230,7 @@ "Teams_Select_a_team": "Selecionar uma equipe", "Teams_Search_teams": "Pesquisar equipes", "Teams_New_Read_only_Label": "Somente leitura", + "Temporarily_unavailable": "Temporariamente indisponÃvel", "Technology_Services": "Serviços tecnológicos", "Upgrade_tab_connection_error_description": "Parece que você não está com conexão com a internet. Isso pode ser porque sua workspace está instalada num servidor totalmente seguro sem acesso à internet", "Terms": "Termos", @@ -4378,6 +4401,8 @@ "onboarding.form.registeredServerForm.continueStandalone": "Continuar como standalone", "transfer-livechat-guest": "Transferir convidados do livechat", "transfer-livechat-guest_description": "Permissão para transferir convidados do livechat", + "Transfer_to": "Transferir para", + "Transfer_call": "Transferir chamada", "Transferred": "Transferido", "Translate": "Traduzir", "Translated": "Traduzido", @@ -4449,6 +4474,7 @@ "unarchive-room": "Desarquivar Sala", "unarchive-room_description": "Permissão para desarquivar canais", "Unassigned": "Não atribuÃdo", + "Unassign_extension": "Desatribuir extensão", "unauthorized": "Não autorizado", "Unavailable": "IndisponÃvel", "Unblock_User": "Desbloquear usuário", @@ -4581,6 +4607,7 @@ "User_uploaded_a_file_to_you": "<strong>{{username}}</strong> enviou um arquivo para você", "User_uploaded_file": "Carregou um arquivo", "User_uploaded_image": "Carregou uma imagem", + "User_extension_not_found": "Extensão do usuário não encontrada", "user-generate-access-token": "Usuário pode gerar token de acesso", "user-generate-access-token_description": "Permissão para usuários gerarem tokens de acesso", "UserData_EnableDownload": "Ativar o download de dados do usuário", @@ -4650,6 +4677,7 @@ "Video_message": "Mensagem de vÃdeo", "Videocall_declined": "Chamada de vÃdeo negada.", "Video_and_Audio_Call": "Chamadas de vÃdeo e áudio", + "Video_call": "Chamada de vÃdeo", "Videos": "VÃdeos", "View_mode": "Modo de visualização", "View_All": "Ver todos os membros", @@ -4739,6 +4767,10 @@ "Visitor_page_URL": "URL da página do visitante", "Visitor_time_on_site": "Tempo do visitante no site", "Voice_Call": "Chamada de voz", + "Voice_call": "Chamada de voz", + "Voice_calling_disabled": "Chamadas de voz desabilitadas", + "Voice_calling_enabled": "Chamadas de voz habilitadas", + "Voice_calling_registration_failed": "Falha no registro de chamada de voz", "VoIP_Enable_Keep_Alive_For_Unstable_Networks": "SIP Options Keep Alive habilitado", "VoIP_Enable_Keep_Alive_For_Unstable_Networks_Description": "Monitore o status de múltiplos gateways SIP externos enviando mensagens SIP OPTIONS periódicas. Usado para redes instáveis.", "VoIP_Enabled": "Canal de voz habilitado", diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index 15b1339c560d07fc8187f7ae8e645bba37282d5c..646a78a4d815b255d2523da6e3141c331f7327df 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -1,4 +1,4 @@ -import type { ISetting, Serialized, SettingValue } from '@rocket.chat/core-typings'; +import type { ISetting, IUser, Serialized, SettingValue } from '@rocket.chat/core-typings'; import type { ServerMethodName, ServerMethodParameters, ServerMethodReturn } from '@rocket.chat/ddp-client'; import { Emitter } from '@rocket.chat/emitter'; import languages from '@rocket.chat/i18n/dist/languages'; @@ -257,6 +257,13 @@ export class MockedAppRootBuilder { return this; } + withUser(user: IUser): this { + this.user.userId = user._id; + this.user.user = user; + + return this; + } + withRole(role: string): this { if (!this.user.user) { throw new Error('user is not defined'); diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index bd660c05191d28395283502fc9cb490036632a2c..83c499a1356e206fdb5b050cae25f7fb376694d3 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -402,4 +402,8 @@ export interface IUsersModel extends IBaseModel<IUser> { findOnlineButNotAvailableAgents(userIds: string[] | null): FindCursor<Pick<ILivechatAgent, '_id' | 'openBusinessHours'>>; findAgentsAvailableWithoutBusinessHours(userIds: string[] | null): FindCursor<Pick<ILivechatAgent, '_id' | 'openBusinessHours'>>; updateLivechatStatusByAgentIds(userIds: string[], status: ILivechatAgentStatus): Promise<UpdateResult>; + findOneByFreeSwitchExtension<T = IUser>(extension: string, options?: FindOptions<IUser>): Promise<T | null>; + setFreeSwitchExtension(userId: string, extension: string | undefined): Promise<UpdateResult>; + findAssignedFreeSwitchExtensions(): FindCursor<string>; + findUsersWithAssignedFreeSwitchExtensions<T = IUser>(options?: FindOptions<IUser>): FindCursor<T>; } diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 94ee32f2f5bec25b21b41ec02aa87de09877eba9..eda85b03dbc86beb7d9450ede834b1a4fccf2c72 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -47,6 +47,7 @@ import type { TeamsEndpoints } from './v1/teams'; import type { UsersEndpoints } from './v1/users'; import type { VideoConferenceEndpoints } from './v1/videoConference'; import type { VoipEndpoints } from './v1/voip'; +import type { VoipFreeSwitchEndpoints } from './v1/voip-freeswitch'; import type { WebdavEndpoints } from './v1/webdav'; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -99,6 +100,7 @@ export interface Endpoints CalendarEndpoints, AuthEndpoints, ImportEndpoints, + VoipFreeSwitchEndpoints, DefaultEndpoints {} type OperationsByPathPatternAndMethod< @@ -260,6 +262,7 @@ export * from './v1/e2e/e2eUpdateGroupKeyParamsPOST'; export * from './v1/e2e'; export * from './v1/import'; export * from './v1/voip'; +export * from './v1/voip-freeswitch'; export * from './v1/email-inbox'; export * from './v1/calendar'; export * from './v1/federation'; diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 28c7ad52337bf7a55ce0fd688e943c6cd7e1545c..6d4e62331bcf88bac0669c7713b95ec3b69bdf10 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -114,7 +114,18 @@ export type UserPersonalTokens = Pick<IPersonalAccessToken, 'name' | 'lastTokenP export type DefaultUserInfo = Pick< IUser, - '_id' | 'username' | 'name' | 'status' | 'roles' | 'emails' | 'active' | 'avatarETag' | 'lastLogin' | 'type' | 'federated' + | '_id' + | 'username' + | 'name' + | 'status' + | 'roles' + | 'emails' + | 'active' + | 'avatarETag' + | 'lastLogin' + | 'type' + | 'federated' + | 'freeSwitchExtension' >; export type UsersEndpoints = { diff --git a/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionAssignProps.ts b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionAssignProps.ts new file mode 100644 index 0000000000000000000000000000000000000000..7fcaf6c6a9a18769e3b8ccfe0d89cf11fa4ee9b3 --- /dev/null +++ b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionAssignProps.ts @@ -0,0 +1,24 @@ +import type { JSONSchemaType } from 'ajv'; +import Ajv from 'ajv'; + +const ajv = new Ajv(); + +export type VoipFreeSwitchExtensionAssignProps = { username: string; extension?: string }; + +const voipFreeSwitchExtensionAssignPropsSchema: JSONSchemaType<VoipFreeSwitchExtensionAssignProps> = { + type: 'object', + properties: { + username: { + type: 'string', + nullable: false, + }, + extension: { + type: 'string', + nullable: true, + }, + }, + required: ['username'], + additionalProperties: false, +}; + +export const isVoipFreeSwitchExtensionAssignProps = ajv.compile(voipFreeSwitchExtensionAssignPropsSchema); diff --git a/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetDetailsProps.ts b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetDetailsProps.ts new file mode 100644 index 0000000000000000000000000000000000000000..a41ab7cd562a37e5e072c6834ed5831b461b8cfb --- /dev/null +++ b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetDetailsProps.ts @@ -0,0 +1,27 @@ +import type { JSONSchemaType } from 'ajv'; +import Ajv from 'ajv'; + +const ajv = new Ajv(); + +export type VoipFreeSwitchExtensionGetDetailsProps = { + extension: string; + group?: string; +}; + +const voipFreeSwitchExtensionGetDetailsPropsSchema: JSONSchemaType<VoipFreeSwitchExtensionGetDetailsProps> = { + type: 'object', + properties: { + extension: { + type: 'string', + nullable: false, + }, + group: { + type: 'string', + nullable: true, + }, + }, + required: ['extension'], + additionalProperties: false, +}; + +export const isVoipFreeSwitchExtensionGetDetailsProps = ajv.compile(voipFreeSwitchExtensionGetDetailsPropsSchema); diff --git a/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetInfoProps.ts b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetInfoProps.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ff871296757c5aaa9919dddd5cc24e923d43321 --- /dev/null +++ b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetInfoProps.ts @@ -0,0 +1,22 @@ +import type { JSONSchemaType } from 'ajv'; +import Ajv from 'ajv'; + +const ajv = new Ajv(); + +export type VoipFreeSwitchExtensionGetInfoProps = { + userId: string; +}; + +const voipFreeSwitchExtensionGetInfoPropsSchema: JSONSchemaType<VoipFreeSwitchExtensionGetInfoProps> = { + type: 'object', + properties: { + userId: { + type: 'string', + nullable: false, + }, + }, + required: ['userId'], + additionalProperties: false, +}; + +export const isVoipFreeSwitchExtensionGetInfoProps = ajv.compile(voipFreeSwitchExtensionGetInfoPropsSchema); diff --git a/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionListProps.ts b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionListProps.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1c010afb32620d255b36e91984e073929ca5952 --- /dev/null +++ b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionListProps.ts @@ -0,0 +1,28 @@ +import type { JSONSchemaType } from 'ajv'; +import Ajv from 'ajv'; + +const ajv = new Ajv(); + +export type VoipFreeSwitchExtensionListProps = { + username?: string; + type?: 'available' | 'free' | 'allocated' | 'all'; +}; + +const voipFreeSwitchExtensionListPropsSchema: JSONSchemaType<VoipFreeSwitchExtensionListProps> = { + type: 'object', + properties: { + username: { + type: 'string', + nullable: true, + }, + type: { + type: 'string', + enum: ['available', 'free', 'allocated', 'all'], + nullable: true, + }, + }, + required: [], + additionalProperties: false, +}; + +export const isVoipFreeSwitchExtensionListProps = ajv.compile(voipFreeSwitchExtensionListPropsSchema); diff --git a/packages/rest-typings/src/v1/voip-freeswitch/index.ts b/packages/rest-typings/src/v1/voip-freeswitch/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..013e4e0351cbbad9b10d5b3b59e46741de5ad8d3 --- /dev/null +++ b/packages/rest-typings/src/v1/voip-freeswitch/index.ts @@ -0,0 +1,29 @@ +import type { FreeSwitchExtension } from '@rocket.chat/core-typings'; + +import type { VoipFreeSwitchExtensionAssignProps } from './VoipFreeSwitchExtensionAssignProps'; +import type { VoipFreeSwitchExtensionGetDetailsProps } from './VoipFreeSwitchExtensionGetDetailsProps'; +import type { VoipFreeSwitchExtensionGetInfoProps } from './VoipFreeSwitchExtensionGetInfoProps'; +import type { VoipFreeSwitchExtensionListProps } from './VoipFreeSwitchExtensionListProps'; + +export * from './VoipFreeSwitchExtensionAssignProps'; +export * from './VoipFreeSwitchExtensionGetDetailsProps'; +export * from './VoipFreeSwitchExtensionGetInfoProps'; +export * from './VoipFreeSwitchExtensionListProps'; + +export type VoipFreeSwitchEndpoints = { + '/v1/voip-freeswitch.extension.list': { + GET: (params: VoipFreeSwitchExtensionListProps) => { extensions: FreeSwitchExtension[] }; + }; + '/v1/voip-freeswitch.extension.getDetails': { + GET: (params: VoipFreeSwitchExtensionGetDetailsProps) => FreeSwitchExtension & { userId?: string; username?: string; name?: string }; + }; + '/v1/voip-freeswitch.extension.assign': { + POST: (params: VoipFreeSwitchExtensionAssignProps) => void; + }; + '/v1/voip-freeswitch.extension.getRegistrationInfoByUserId': { + GET: (params: VoipFreeSwitchExtensionGetInfoProps) => { + extension: FreeSwitchExtension; + credentials: { password: string; websocketPath: string }; + }; + }; +}; diff --git a/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx b/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx index ddd9efe142cee774fe472741187d4db3d7bd8227..920c4aa43502bed076bffbd113eb14c75c99ad35 100644 --- a/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx +++ b/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx @@ -1,5 +1,6 @@ import { IconButton, MenuItem, MenuSection, MenuV2 } from '@rocket.chat/fuselage'; import type { ComponentProps, ReactNode } from 'react'; +import { cloneElement } from 'react'; import { useTranslation } from 'react-i18next'; import type { GenericMenuItemProps } from './GenericMenuItem'; @@ -29,7 +30,7 @@ type GenericMenuConditionalProps = type GenericMenuProps = GenericMenuCommonProps & GenericMenuConditionalProps & Omit<ComponentProps<typeof MenuV2>, 'children'>; -const GenericMenu = ({ title, icon = 'menu', disabled, onAction, callbackAction, ...props }: GenericMenuProps) => { +const GenericMenu = ({ title, icon = 'menu', disabled, onAction, callbackAction, button, className, ...props }: GenericMenuProps) => { const { t, i18n } = useTranslation(); const sections = 'sections' in props && props.sections; @@ -47,7 +48,13 @@ const GenericMenu = ({ title, icon = 'menu', disabled, onAction, callbackAction, const isMenuEmpty = !(sections && sections.length > 0) && !(items && items.length > 0); if (isMenuEmpty || disabled) { - return <IconButton small icon={icon} disabled />; + if (button) { + // FIXME: deprecate prop `button` as there's no way to ensure it is actually a button + // (e.g cloneElement could be passing props to a fragment) + return cloneElement(button, { small: true, icon, disabled, title, className }); + } + + return <IconButton small icon={icon} className={className} title={title} disabled />; } return ( @@ -80,6 +87,8 @@ const GenericMenu = ({ title, icon = 'menu', disabled, onAction, callbackAction, icon={icon} title={i18n.exists(title) ? t(title) : title} onAction={onAction || handleAction} + className={className} + button={button} {...(disabledKeys && { disabledKeys })} {...props} > diff --git a/packages/ui-voip/.eslintignore b/packages/ui-voip/.eslintignore new file mode 100644 index 0000000000000000000000000000000000000000..608841ff3853c2c0ed3018b6672d9f4932cd908d --- /dev/null +++ b/packages/ui-voip/.eslintignore @@ -0,0 +1 @@ +!.storybook diff --git a/packages/ui-voip/.eslintrc.json b/packages/ui-voip/.eslintrc.json new file mode 100644 index 0000000000000000000000000000000000000000..465fd738d4e4b5273281e41614e17f4685810eb7 --- /dev/null +++ b/packages/ui-voip/.eslintrc.json @@ -0,0 +1,67 @@ +{ + "extends": [ + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/eslint-recommended", + "@rocket.chat/eslint-config/original", + "@rocket.chat/eslint-config/react", + "prettier", + "plugin:anti-trojan-source/recommended", + "plugin:react/jsx-runtime", + "plugin:storybook/recommended" + ], + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint", "prettier"], + "rules": { + "func-call-spacing": "off", + "import/named": "error", + "import/order": [ + "error", + { + "newlines-between": "always", + "groups": ["builtin", "external", "internal", ["parent", "sibling", "index"]], + "alphabetize": { + "order": "asc" + } + } + ], + "indent": "off", + "jsx-quotes": ["error", "prefer-single"], + "new-cap": ["error"], + "no-extra-parens": "off", + "no-spaced-func": "off", + "no-undef": "off", + "no-unused-vars": "off", + "no-useless-constructor": "off", + "no-use-before-define": "off", + "prefer-arrow-callback": ["error", { "allowNamedFunctions": true }], + "prettier/prettier": 2 + }, + "settings": { + "import/resolver": { + "node": { + "extensions": [".js", ".ts", ".tsx"] + } + } + }, + "ignorePatterns": ["**/dist"], + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "rules": { + "arrow-body-style": "off", + "@typescript-eslint/ban-ts-ignore": "off", + // "@typescript-eslint/explicit-function-return-type": "warn", + "@typescript-eslint/indent": "off", + "@typescript-eslint/no-extra-parens": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_" + } + ], + "@typescript-eslint/prefer-optional-chain": "warn" + } + } + ] +} diff --git a/packages/ui-voip/.storybook/main.js b/packages/ui-voip/.storybook/main.js new file mode 100644 index 0000000000000000000000000000000000000000..dc95f3584a3f968ab43dd4d22d8594a9a0b8c8fb --- /dev/null +++ b/packages/ui-voip/.storybook/main.js @@ -0,0 +1,12 @@ +/** @type {import('@storybook/react/types').StorybookConfig} */ +module.exports = { + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-a11y'], + framework: '@storybook/react', + features: { + postcss: false, + }, + typescript: { + reactDocgen: 'react-docgen-typescript-plugin', + }, +}; diff --git a/packages/ui-voip/.storybook/preview.js b/packages/ui-voip/.storybook/preview.js new file mode 100644 index 0000000000000000000000000000000000000000..d1e44f0c2b8c0bf04caf7697aff6fdc8a3b2e808 --- /dev/null +++ b/packages/ui-voip/.storybook/preview.js @@ -0,0 +1,26 @@ +import '../../../apps/meteor/app/theme/client/main.css'; +import 'highlight.js/styles/github.css'; +import '@rocket.chat/icons/dist/rocketchat.css'; + +export const parameters = { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, +}; + +export const decorators = [ + (Story) => ( + <div className='rc-old'> + <style>{` + body { + background-color: white; + } + `}</style> + <Story /> + </div> + ), +]; diff --git a/packages/ui-voip/jest.config.ts b/packages/ui-voip/jest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1525e14dff0b63505b88d698e24d27820cdac4c --- /dev/null +++ b/packages/ui-voip/jest.config.ts @@ -0,0 +1,13 @@ +import client from '@rocket.chat/jest-presets/client'; +import type { Config } from 'jest'; + +export default { + preset: client.preset, + setupFilesAfterEnv: [...client.setupFilesAfterEnv], + moduleNameMapper: { + '^react($|/.+)': '<rootDir>/../../node_modules/react$1', + '^react-dom/client$': '<rootDir>/../../node_modules/react-dom$1', + '^react-dom($|/.+)': '<rootDir>/../../node_modules/react-dom$1', + '^react-i18next($|/.+)': '<rootDir>/../../node_modules/react-i18next$1', + }, +} satisfies Config; diff --git a/packages/ui-voip/package.json b/packages/ui-voip/package.json new file mode 100644 index 0000000000000000000000000000000000000000..d02a8a8e1a665379907fd2d99a7edb53e1cf3613 --- /dev/null +++ b/packages/ui-voip/package.json @@ -0,0 +1,78 @@ +{ + "name": "@rocket.chat/ui-voip", + "version": "1.0.0", + "private": true, + "scripts": { + "eslint": "eslint --ext .js,.jsx,.ts,.tsx .", + "eslint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", + "test": "jest", + "testunit": "jest", + "build": "tsc -p tsconfig.build.json", + "storybook": "start-storybook -p 6006", + "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", + "typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json" + }, + "dependencies": { + "@rocket.chat/emitter": "~0.31.25", + "@tanstack/react-query": "^4.16.1", + "react-i18next": "~15.0.1", + "sip.js": "^0.20.1" + }, + "devDependencies": { + "@babel/core": "~7.22.20", + "@faker-js/faker": "~8.0.2", + "@rocket.chat/css-in-js": "~0.31.25", + "@rocket.chat/eslint-config": "workspace:^", + "@rocket.chat/fuselage": "^0.59.1", + "@rocket.chat/fuselage-hooks": "^0.33.1", + "@rocket.chat/icons": "~0.38.0", + "@rocket.chat/jest-presets": "workspace:~", + "@rocket.chat/mock-providers": "workspace:~", + "@rocket.chat/styled": "~0.31.25", + "@rocket.chat/ui-avatar": "workspace:^", + "@rocket.chat/ui-client": "workspace:^", + "@rocket.chat/ui-contexts": "workspace:^", + "@storybook/addon-a11y": "^6.5.16", + "@storybook/addon-actions": "~6.5.16", + "@storybook/addon-docs": "~6.5.16", + "@storybook/addon-essentials": "~6.5.16", + "@storybook/builder-webpack4": "~6.5.16", + "@storybook/manager-webpack4": "~6.5.16", + "@storybook/react": "~6.5.16", + "@storybook/testing-library": "~0.0.13", + "@storybook/testing-react": "~1.3.0", + "@testing-library/react": "~16.0.1", + "@testing-library/user-event": "~14.5.2", + "@types/jest": "~29.5.12", + "@types/jest-axe": "~3.5.9", + "eslint": "~8.45.0", + "eslint-plugin-react": "~7.32.2", + "eslint-plugin-react-hooks": "~4.6.0", + "eslint-plugin-storybook": "~0.6.15", + "jest": "~29.7.0", + "jest-axe": "~9.0.0", + "react-docgen-typescript-plugin": "~1.0.8", + "typescript": "~5.5.4" + }, + "peerDependencies": { + "@rocket.chat/css-in-js": "*", + "@rocket.chat/fuselage": "*", + "@rocket.chat/fuselage-hooks": "*", + "@rocket.chat/icons": "*", + "@rocket.chat/styled": "*", + "@rocket.chat/ui-avatar": "*", + "@rocket.chat/ui-client": "*", + "@rocket.chat/ui-contexts": "*", + "react": "^17.0.2", + "react-aria": "~3.23.1", + "react-dom": "^17.0.2" + }, + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "/dist" + ], + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.spec.tsx b/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..73ae2ef31634307b30f9e7ceb021c16c216e6c2b --- /dev/null +++ b/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.spec.tsx @@ -0,0 +1,19 @@ +import { composeStories } from '@storybook/react'; +import { render } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import * as stories from './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(<Story />, { legacyRoot: true }); + expect(tree.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(<Story />, { legacyRoot: true }); + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..a19e55cf3a7c2e8fee624dfc691517d5d56ebcd1 --- /dev/null +++ b/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.stories.tsx @@ -0,0 +1,22 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { ReactElement } from 'react'; + +import VoipActionButton from './VoipActionButton'; + +export default { + title: 'Components/VoipActionButton', + component: VoipActionButton, + decorators: [(Story): ReactElement => <Story />], +} satisfies ComponentMeta<typeof VoipActionButton>; + +export const SuccessButton: ComponentStory<typeof VoipActionButton> = () => { + return <VoipActionButton success icon='phone' label='Success Button' />; +}; + +export const DangerButton: ComponentStory<typeof VoipActionButton> = () => { + return <VoipActionButton danger icon='phone' label='Danger Button' />; +}; + +export const NeutralButton: ComponentStory<typeof VoipActionButton> = () => { + return <VoipActionButton icon='phone' label='Neutral Button' />; +}; diff --git a/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.tsx b/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..17f3e847322c3f9c3ef0bc8d5e94e12d027f7dca --- /dev/null +++ b/packages/ui-voip/src/components/VoipActionButton/VoipActionButton.tsx @@ -0,0 +1,27 @@ +import { Icon, IconButton } from '@rocket.chat/fuselage'; +import type { Keys } from '@rocket.chat/icons'; +import type { ComponentProps } from 'react'; + +type ActionButtonProps = Pick<ComponentProps<typeof IconButton>, 'className' | 'disabled' | 'pressed' | 'danger' | 'success'> & { + label: string; + icon: Keys; + onClick?: () => void; +}; + +const VoipActionButton = ({ disabled, label, pressed, icon, danger, success, className, onClick }: ActionButtonProps) => ( + <IconButton + medium + danger={danger} + success={success} + secondary={success || danger} + className={className} + icon={<Icon name={icon} />} + 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 new file mode 100644 index 0000000000000000000000000000000000000000..2f4b29aa9de8b5aa3800b6d785dcb85b19ea50e6 --- /dev/null +++ b/packages/ui-voip/src/components/VoipActionButton/__snapshots__/VoipActionButton.spec.tsx.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders DangerButton without crashing 1`] = ` +<body> + <div> + <button + aria-label="Danger Button" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--icon-secondary-danger rcx-button--square rcx-button--icon rcx-button" + title="Danger Button" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-phone rcx-icon" + > + î¢ + </i> + </button> + </div> +</body> +`; + +exports[`renders NeutralButton without crashing 1`] = ` +<body> + <div> + <button + aria-label="Neutral Button" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--square rcx-button--icon rcx-button" + title="Neutral Button" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-phone rcx-icon" + > + î¢ + </i> + </button> + </div> +</body> +`; + +exports[`renders SuccessButton without crashing 1`] = ` +<body> + <div> + <button + aria-label="Success Button" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--icon-secondary-success rcx-button--square rcx-button--icon rcx-button" + title="Success Button" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-phone rcx-icon" + > + î¢ + </i> + </button> + </div> +</body> +`; diff --git a/packages/ui-voip/src/components/VoipActionButton/index.ts b/packages/ui-voip/src/components/VoipActionButton/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e6352647f2f6282fc58d778444d227c38532794 --- /dev/null +++ b/packages/ui-voip/src/components/VoipActionButton/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..4d7c9adc0160a780c6f5dda659c0cf0b4e91d0a5 --- /dev/null +++ b/packages/ui-voip/src/components/VoipActions/VoipActions.spec.tsx @@ -0,0 +1,20 @@ +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(<Story />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + expect(tree.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(<Story />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..0a1b4a48324e76770166a864a75dce7d1bea6afd --- /dev/null +++ b/packages/ui-voip/src/components/VoipActions/VoipActions.stories.tsx @@ -0,0 +1,24 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { ReactElement } from 'react'; + +import VoipActions from './VoipActions'; + +const noop = () => undefined; + +export default { + title: 'Components/VoipActions', + component: VoipActions, + decorators: [(Story): ReactElement => <Story />], +} satisfies ComponentMeta<typeof VoipActions>; + +export const IncomingActions: ComponentStory<typeof VoipActions> = () => { + return <VoipActions onDecline={noop} onAccept={noop} />; +}; + +export const OngoingActions: ComponentStory<typeof VoipActions> = () => { + return <VoipActions onEndCall={noop} onDTMF={noop} onHold={noop} onMute={noop} onTransfer={noop} />; +}; + +export const OutgoingActions: ComponentStory<typeof VoipActions> = () => { + return <VoipActions onEndCall={noop} />; +}; diff --git a/packages/ui-voip/src/components/VoipActions/VoipActions.tsx b/packages/ui-voip/src/components/VoipActions/VoipActions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0cd3c95a2dc2181916d57172d26d4f2b4cece923 --- /dev/null +++ b/packages/ui-voip/src/components/VoipActions/VoipActions.tsx @@ -0,0 +1,85 @@ +import { ButtonGroup } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +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(); + + return ( + <ButtonGroup large> + {isIncoming(events) && <ActionButton danger label={t('Decline')} icon='phone-off' onClick={events.onDecline} />} + + <ActionButton + label={isMuted ? t('Turn_on_microphone') : t('Turn_off_microphone')} + icon='mic-off' + pressed={isMuted} + disabled={!events.onMute} + onClick={() => events.onMute?.(!isMuted)} + /> + + {!isIncoming(events) && ( + <ActionButton + label={isHeld ? t('Resume') : t('Hold')} + icon='pause-shape-unfilled' + pressed={isHeld} + disabled={!events.onHold} + onClick={() => events.onHold?.(!isHeld)} + /> + )} + + <ActionButton + label={isDTMFActive ? t('Close_Dialpad') : t('Open_Dialpad')} + icon='dialpad' + pressed={isDTMFActive} + disabled={!events.onDTMF} + onClick={events.onDTMF} + /> + + <ActionButton + label={t('Transfer_call')} + icon='arrow-forward' + pressed={isTransferActive} + disabled={!events.onTransfer} + onClick={events.onTransfer} + /> + + {isOngoing(events) && <ActionButton danger label={t('End_call')} icon='phone-off' disabled={isHeld} onClick={events.onEndCall} />} + + {isIncoming(events) && <ActionButton success label={t('Accept')} icon='phone' onClick={events.onAccept} />} + </ButtonGroup> + ); +}; + +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 new file mode 100644 index 0000000000000000000000000000000000000000..093545e64d058858a85f4d1963a33a05edc7d37b --- /dev/null +++ b/packages/ui-voip/src/components/VoipActions/__snapshots__/VoipActions.spec.tsx.snap @@ -0,0 +1,239 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders IncomingActions without crashing 1`] = ` +<body> + <div> + <div + class="rcx-button-group rcx-button-group--align-start rcx-button-group--large" + role="group" + > + <button + aria-label="Decline" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--icon-secondary-danger rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + title="Decline" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-phone-off rcx-icon" + > +  + </i> + </button> + <button + aria-label="Turn_off_microphone" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + disabled="" + title="Turn_off_microphone" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-mic-off rcx-icon" + > + îš + </i> + </button> + <button + aria-label="Open_Dialpad" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + disabled="" + title="Open_Dialpad" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-dialpad rcx-icon" + > +  + </i> + </button> + <button + aria-label="Transfer_call" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + disabled="" + title="Transfer_call" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-arrow-forward rcx-icon" + > + î„ + </i> + </button> + <button + aria-label="Accept" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--icon-secondary-success rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + title="Accept" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-phone rcx-icon" + > + î¢ + </i> + </button> + </div> + </div> +</body> +`; + +exports[`renders OngoingActions without crashing 1`] = ` +<body> + <div> + <div + class="rcx-button-group rcx-button-group--align-start rcx-button-group--large" + role="group" + > + <button + aria-label="Turn_off_microphone" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + title="Turn_off_microphone" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-mic-off rcx-icon" + > + îš + </i> + </button> + <button + aria-label="Hold" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + title="Hold" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-pause-shape-unfilled rcx-icon" + > + î…› + </i> + </button> + <button + aria-label="Open_Dialpad" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + title="Open_Dialpad" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-dialpad rcx-icon" + > +  + </i> + </button> + <button + aria-label="Transfer_call" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + title="Transfer_call" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-arrow-forward rcx-icon" + > + î„ + </i> + </button> + <button + aria-label="End_call" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--icon-secondary-danger rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + title="End_call" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-phone-off rcx-icon" + > +  + </i> + </button> + </div> + </div> +</body> +`; + +exports[`renders OutgoingActions without crashing 1`] = ` +<body> + <div> + <div + class="rcx-button-group rcx-button-group--align-start rcx-button-group--large" + role="group" + > + <button + aria-label="Turn_off_microphone" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + disabled="" + title="Turn_off_microphone" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-mic-off rcx-icon" + > + îš + </i> + </button> + <button + aria-label="Hold" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + disabled="" + title="Hold" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-pause-shape-unfilled rcx-icon" + > + î…› + </i> + </button> + <button + aria-label="Open_Dialpad" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + disabled="" + title="Open_Dialpad" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-dialpad rcx-icon" + > +  + </i> + </button> + <button + aria-label="Transfer_call" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + disabled="" + title="Transfer_call" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-arrow-forward rcx-icon" + > + î„ + </i> + </button> + <button + aria-label="End_call" + class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--icon-secondary-danger rcx-button--square rcx-button--icon rcx-button rcx-button-group__item" + title="End_call" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-phone-off rcx-icon" + > +  + </i> + </button> + </div> + </div> +</body> +`; diff --git a/packages/ui-voip/src/components/VoipActions/index.ts b/packages/ui-voip/src/components/VoipActions/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb8ae6407ce32464894d46460d3c5e09956823e6 --- /dev/null +++ b/packages/ui-voip/src/components/VoipActions/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..595c6897eeec26129ec575108e479575b9d1f6d2 --- /dev/null +++ b/packages/ui-voip/src/components/VoipContactId/VoipContactId.spec.tsx @@ -0,0 +1,65 @@ +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 { 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]); + +describe('VoipContactId', () => { + beforeAll(() => { + Object.assign(navigator, { + clipboard: { + writeText: jest.fn(), + }, + }); + }); + + test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const tree = render(<Story />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + expect(tree.baseElement).toMatchSnapshot(); + }); + + test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(<Story />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should display avatar and name when username is available', () => { + render(<VoipContactId name='John Doe' username='john.doe' />, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + + expect(screen.getByRole('img', { hidden: true })).toHaveAttribute('title', 'john.doe'); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + }); + + it('should display transferedBy information when available', () => { + render(<VoipContactId name='John Doe' username='john.doe' transferedBy='Jane Doe' />, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + + expect(screen.getByText('From: Jane Doe')).toBeInTheDocument(); + }); + + it('should display copy button when username isnt available', async () => { + const phone = faker.phone.number(); + render(<VoipContactId name={phone} />, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + + const copyButton = screen.getByRole('button', { name: 'Copy_phone_number' }); + expect(copyButton).toBeInTheDocument(); + + copyButton.click(); + 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 new file mode 100644 index 0000000000000000000000000000000000000000..2706a5381bd66b2d4fb4357b1a30ddb8ee0bf20e --- /dev/null +++ b/packages/ui-voip/src/components/VoipContactId/VoipContactId.stories.tsx @@ -0,0 +1,36 @@ +import { Box } from '@rocket.chat/fuselage'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactElement } from 'react'; + +import VoipContactId from './VoipContactId'; + +export default { + title: 'Components/VoipContactId', + component: VoipContactId, + decorators: [ + (Story): ReactElement => ( + <QueryClientProvider client={new QueryClient()}> + <Box maxWidth={200}> + <Story /> + </Box> + </QueryClientProvider> + ), + ], +} satisfies ComponentMeta<typeof VoipContactId>; + +export const Loading: ComponentStory<typeof VoipContactId> = () => { + return <VoipContactId name='1000' isLoading />; +}; + +export const WithUsername: ComponentStory<typeof VoipContactId> = () => { + return <VoipContactId username='john.doe' name='John Doe' />; +}; + +export const WithTransfer: ComponentStory<typeof VoipContactId> = () => { + return <VoipContactId username='john.doe' transferedBy='Jane Doe' name='John Doe' />; +}; + +export const WithPhoneNumber: ComponentStory<typeof VoipContactId> = () => { + return <VoipContactId name='+554788765522' />; +}; diff --git a/packages/ui-voip/src/components/VoipContactId/VoipContactId.tsx b/packages/ui-voip/src/components/VoipContactId/VoipContactId.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1152309a14da0dd1349a220292d951dcbc0d26c2 --- /dev/null +++ b/packages/ui-voip/src/components/VoipContactId/VoipContactId.tsx @@ -0,0 +1,74 @@ +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 ( + <Box display='flex' pi={12} pb={8}> + <Skeleton variant='rect' size={20} mie={8} /> + <Skeleton variant='text' width={100} height={16} /> + </Box> + ); + } + + return ( + <Box pi={12} pbs={4} pbe={8}> + {transferedBy && ( + <Box mbe={8} fontScale='p2' color='secondary-info'> + {t('From')}: {transferedBy} + </Box> + )} + + <Box display='flex'> + {username && ( + <Box flexShrink={0} mie={8}> + <UserAvatar username={username} size='x24' /> + </Box> + )} + + <Box withTruncatedText is='p' fontScale='p1' mie='auto' color='secondary-info' flexGrow={1} flexShrink={1} title={name}> + {name} + </Box> + + {!username && ( + <IconButton + mini + aria-label={t('Copy_phone_number')} + data-tooltip={t('Copy_phone_number')} + mis={6} + icon='copy' + onClick={() => handleCopy.mutate(name)} + /> + )} + </Box> + </Box> + ); +}; + +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 new file mode 100644 index 0000000000000000000000000000000000000000..e8d94cbfdde44184f9ede6aa4d444b539a01c4ea --- /dev/null +++ b/packages/ui-voip/src/components/VoipContactId/__snapshots__/VoipContactId.spec.tsx.snap @@ -0,0 +1,147 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VoipContactId renders Loading without crashing 1`] = ` +<body> + <div> + <div + class="rcx-box rcx-box--full rcx-css-1k9c77o" + > + <div + class="rcx-box rcx-box--full rcx-css-1d60bfv" + > + <span + class="rcx-box rcx-box--full rcx-skeleton--rect rcx-skeleton rcx-css-1ad23aw" + /> + <span + class="rcx-box rcx-box--full rcx-skeleton--text rcx-skeleton rcx-css-fuw2gp" + /> + </div> + </div> + </div> +</body> +`; + +exports[`VoipContactId renders WithPhoneNumber without crashing 1`] = ` +<body> + <div> + <div + class="rcx-box rcx-box--full rcx-css-1k9c77o" + > + <div + class="rcx-box rcx-box--full rcx-css-1v88l6i" + > + <div + class="rcx-box rcx-box--full rcx-css-1qvl0ud" + > + <p + class="rcx-box rcx-box--full rcx-css-v42v30" + title="+554788765522" + > + +554788765522 + </p> + <button + aria-label="Copy_phone_number" + class="rcx-box rcx-box--full rcx-button--mini-square rcx-button--square rcx-button--icon rcx-button rcx-css-1azzyc" + data-tooltip="Copy_phone_number" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-copy rcx-icon rcx-css-1tg7yd0" + > + î€ + </i> + </button> + </div> + </div> + </div> + </div> +</body> +`; + +exports[`VoipContactId renders WithTransfer without crashing 1`] = ` +<body> + <div> + <div + class="rcx-box rcx-box--full rcx-css-1k9c77o" + > + <div + class="rcx-box rcx-box--full rcx-css-1v88l6i" + > + <div + class="rcx-box rcx-box--full rcx-css-1w6swlw" + > + From + : + Jane Doe + </div> + <div + class="rcx-box rcx-box--full rcx-css-1qvl0ud" + > + <div + class="rcx-box rcx-box--full rcx-css-1keant8" + > + <figure + class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x24" + > + <img + aria-hidden="true" + class="rcx-avatar__element rcx-avatar__element--x24" + data-username="john.doe" + src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg==" + title="john.doe" + /> + </figure> + </div> + <p + class="rcx-box rcx-box--full rcx-css-v42v30" + title="John Doe" + > + John Doe + </p> + </div> + </div> + </div> + </div> +</body> +`; + +exports[`VoipContactId renders WithUsername without crashing 1`] = ` +<body> + <div> + <div + class="rcx-box rcx-box--full rcx-css-1k9c77o" + > + <div + class="rcx-box rcx-box--full rcx-css-1v88l6i" + > + <div + class="rcx-box rcx-box--full rcx-css-1qvl0ud" + > + <div + class="rcx-box rcx-box--full rcx-css-1keant8" + > + <figure + class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x24" + > + <img + aria-hidden="true" + class="rcx-avatar__element rcx-avatar__element--x24" + data-username="john.doe" + src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg==" + title="john.doe" + /> + </figure> + </div> + <p + class="rcx-box rcx-box--full rcx-css-v42v30" + title="John Doe" + > + John Doe + </p> + </div> + </div> + </div> + </div> +</body> +`; diff --git a/packages/ui-voip/src/components/VoipContactId/index.ts b/packages/ui-voip/src/components/VoipContactId/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a50854dfa73b07fcf4386b87ae2824628466186 --- /dev/null +++ b/packages/ui-voip/src/components/VoipContactId/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..602c9813d9483f0bf67f16aa60c8a7bc2a07457e --- /dev/null +++ b/packages/ui-voip/src/components/VoipDialPad/VoipDialPad.spec.tsx @@ -0,0 +1,75 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import DialPad from './VoipDialPad'; + +describe('VoipDialPad', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('should not be editable by default', () => { + render(<DialPad value='' onChange={jest.fn()} />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByLabelText('Phone_number')).toHaveAttribute('readOnly'); + }); + + it('should enable input when editable', () => { + render(<DialPad editable value='' onChange={jest.fn()} />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByLabelText('Phone_number')).not.toHaveAttribute('readOnly'); + }); + + it('should disable backspace button when input is empty', () => { + render(<DialPad editable value='' onChange={jest.fn()} />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByTestId('dial-paid-input-backspace')).toBeDisabled(); + }); + + it('should enable backspace button when input has value', () => { + render(<DialPad editable value='123' onChange={jest.fn()} />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByTestId('dial-paid-input-backspace')).toBeEnabled(); + }); + + it('should remove last character when backspace is clicked', () => { + const fn = jest.fn(); + render(<DialPad editable value='123' onChange={fn} />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByLabelText('Phone_number')).toHaveValue('123'); + + screen.getByTestId('dial-paid-input-backspace').click(); + + expect(fn).toHaveBeenCalledWith('12'); + }); + + it('should call onChange when number is clicked', () => { + const fn = jest.fn(); + render(<DialPad editable value='123' onChange={fn} />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'].forEach((digit) => { + screen.getByTestId(`dial-pad-button-${digit}`).click(); + expect(fn).toHaveBeenCalledWith(`123${digit}`, digit); + }); + }); + + xit('should call onChange with + when 0 pressed and held', () => { + const fn = jest.fn(); + render(<DialPad editable longPress value='123' onChange={fn} />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + const button = screen.getByTestId('dial-pad-button-0'); + + button.click(); + expect(fn).toHaveBeenCalledWith('1230', '0'); + + fireEvent.pointerDown(button); + jest.runOnlyPendingTimers(); + fireEvent.pointerUp(button); + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..4b2371bb05161d68ecbf990adf658c9ce8d2adf1 --- /dev/null +++ b/packages/ui-voip/src/components/VoipDialPad/VoipDialPad.stories.tsx @@ -0,0 +1,21 @@ +import { Box } from '@rocket.chat/fuselage'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { ReactElement } from 'react'; + +import VoipDialPad from './VoipDialPad'; + +export default { + title: 'Components/VoipDialPad', + component: VoipDialPad, + decorators: [ + (Story): ReactElement => ( + <Box maxWidth={248}> + <Story /> + </Box> + ), + ], +} satisfies ComponentMeta<typeof VoipDialPad>; + +export const DialPad: ComponentStory<typeof VoipDialPad> = () => { + return <VoipDialPad value='' onChange={() => undefined} />; +}; diff --git a/packages/ui-voip/src/components/VoipDialPad/VoipDialPad.tsx b/packages/ui-voip/src/components/VoipDialPad/VoipDialPad.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0dad0fb14d5125138e9d3ca96fa13ba14eaca60b --- /dev/null +++ b/packages/ui-voip/src/components/VoipDialPad/VoipDialPad.tsx @@ -0,0 +1,68 @@ +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) => ( + <FocusScope autoFocus> + <Box bg='surface-light'> + <Box display='flex' pi={12} pbs={4} pbe={8} bg='surface-neutral'> + <DialPadInput + value={value} + readOnly={!editable} + onChange={(e) => onChange(e.currentTarget.value)} + onBackpaceClick={() => onChange(value.slice(0, -1))} + /> + </Box> + + <Box className={dialPadClassName} maxWidth={196} mi='auto'> + {DIGITS.map(([primaryDigit, subDigit, longPressDigit]) => ( + <DialPadButton + key={primaryDigit} + digit={primaryDigit} + subDigit={subDigit} + longPressDigit={longPress ? longPressDigit : undefined} + onClick={(digit: string) => onChange(`${value}${digit}`, digit)} + /> + ))} + </Box> + </Box> + </FocusScope> +); + +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 new file mode 100644 index 0000000000000000000000000000000000000000..2ef1bc6e1a4b783815c0d2adcb7294d292340b88 --- /dev/null +++ b/packages/ui-voip/src/components/VoipDialPad/components/VoipDialPadButton.tsx @@ -0,0 +1,49 @@ +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 ( + <Button className={dialPadButtonClass} {...mergeProps(pressProps, longPressProps)} data-testid={`dial-pad-button-${digit}`}> + <Box is='span' fontSize={16} lineHeight={16}> + {digit} + </Box> + <Box is='span' fontSize={12} lineHeight={12} mbs={4} color='hint' aria-hidden> + {subDigit} + </Box> + </Button> + ); +}; + +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 new file mode 100644 index 0000000000000000000000000000000000000000..9723451f7db7fbd96355d190899d4b6fd5246234 --- /dev/null +++ b/packages/ui-voip/src/components/VoipDialPad/components/VoipDialPadInput.tsx @@ -0,0 +1,48 @@ +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<HTMLInputElement>) => void; + onBlur?: (event: FocusEvent<HTMLElement, Element>) => void; +}; + +const className = css` + padding-block: 6px; + min-height: 28px; + height: 28px; +`; + +const VoipDialPadInput = ({ readOnly, value, onChange, onBackpaceClick }: DialPadInputProps) => { + const { t } = useTranslation(); + + return ( + <TextInput + p={0} + readOnly={readOnly} + height='100%' + minHeight={0} + value={value} + className={className} + aria-label={t('Phone_number')} + addon={ + <IconButton + small + icon='backspace' + aria-label={t('Remove_last_character')} + data-testid='dial-paid-input-backspace' + size='14px' + disabled={!value} + onClick={onBackpaceClick} + /> + } + 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 new file mode 100644 index 0000000000000000000000000000000000000000..9c97cebe161513f76de936bebce556d4a849be91 --- /dev/null +++ b/packages/ui-voip/src/components/VoipDialPad/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..9a442eef572d65497ae6fb0113bf0b6bbf53ef92 --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/VoipPopup.spec.tsx @@ -0,0 +1,80 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { composeStories } from '@storybook/testing-react'; +import { render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import { useVoipSession } from '../../hooks/useVoipSession'; +import { createMockVoipSession } from '../../tests/mocks'; +import { replaceReactAriaIds } from '../../tests/utils/replaceReactAriaIds'; +import VoipPopup from './VoipPopup'; +import * as stories from './VoipPopup.stories'; + +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(<VoipPopup />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByTestId('vc-popup-incoming')).toBeInTheDocument(); +}); + +it('should properly render ongoing popup', async () => { + mockedUseVoipSession.mockImplementationOnce(() => createMockVoipSession({ type: 'ONGOING' })); + + render(<VoipPopup />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByTestId('vc-popup-ongoing')).toBeInTheDocument(); +}); + +it('should properly render outgoing popup', async () => { + mockedUseVoipSession.mockImplementationOnce(() => createMockVoipSession({ type: 'OUTGOING' })); + + render(<VoipPopup />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByTestId('vc-popup-outgoing')).toBeInTheDocument(); +}); + +it('should properly render error popup', async () => { + mockedUseVoipSession.mockImplementationOnce(() => createMockVoipSession({ type: 'ERROR' })); + + render(<VoipPopup />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByTestId('vc-popup-error')).toBeInTheDocument(); +}); + +it('should properly render dialer popup', async () => { + render(<VoipPopup />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByTestId('vc-popup-dialer')).toBeInTheDocument(); +}); + +it('should prioritize session over dialer', async () => { + mockedUseVoipSession.mockImplementationOnce(() => createMockVoipSession({ type: 'INCOMING' })); + + render(<VoipPopup />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + 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(<Story />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + expect(replaceReactAriaIds(tree.baseElement)).toMatchSnapshot(); +}); + +test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(<Story />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..128a15071eb85a7c0f4821b2f1db22f61e853d5f --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/VoipPopup.stories.tsx @@ -0,0 +1,91 @@ +import { Emitter } from '@rocket.chat/emitter'; +import { MockedModalContext } from '@rocket.chat/mock-providers'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactElement } from 'react'; + +import { VoipContext } from '../../contexts/VoipContext'; +import type { VoipSession } from '../../definitions'; +import type VoipClient from '../../lib/VoipClient'; +import VoipPopup from './VoipPopup'; + +const MockVoipClient = class extends Emitter { + private _sessionType: VoipSession['type'] = 'INCOMING'; + + setSessionType(type: VoipSession['type']) { + this._sessionType = type; + } + + 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); +}; + +const client = new MockVoipClient(); + +const contextValue = { + isEnabled: true as const, + voipClient: client as unknown as VoipClient, + error: null, + changeAudioInputDevice: async () => undefined, + changeAudioOutputDevice: async () => undefined, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + logger: { + log: console.log, + warn: console.warn, + error: () => undefined, + }, +}); + +export default { + title: 'Components/VoipPopup', + component: VoipPopup, + decorators: [ + (Story): ReactElement => ( + <QueryClientProvider client={queryClient}> + <MockedModalContext> + <VoipContext.Provider value={contextValue}> + <Story /> + </VoipContext.Provider> + </MockedModalContext> + </QueryClientProvider> + ), + ], +} satisfies ComponentMeta<typeof VoipPopup>; + +export const IncomingCall: ComponentStory<typeof VoipPopup> = () => { + client.setSessionType('INCOMING'); + return <VoipPopup />; +}; + +export const OngoingCall: ComponentStory<typeof VoipPopup> = () => { + client.setSessionType('ONGOING'); + return <VoipPopup />; +}; + +export const OutgoingCall: ComponentStory<typeof VoipPopup> = () => { + client.setSessionType('OUTGOING'); + return <VoipPopup />; +}; + +export const ErrorCall: ComponentStory<typeof VoipPopup> = () => { + client.setSessionType('ERROR'); + return <VoipPopup />; +}; diff --git a/packages/ui-voip/src/components/VoipPopup/VoipPopup.tsx b/packages/ui-voip/src/components/VoipPopup/VoipPopup.tsx new file mode 100644 index 0000000000000000000000000000000000000000..71c86b2df5931e90caa1071e7b0be97d955e70e5 --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/VoipPopup.tsx @@ -0,0 +1,38 @@ +import { isVoipErrorSession, isVoipIncomingSession, isVoipOngoingSession, isVoipOutgoingSession } from '../../definitions'; +import { useVoipDialer } from '../../hooks/useVoipDialer'; +import { useVoipSession } from '../../hooks/useVoipSession'; +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'; + +const VoipPopup = ({ position }: { position?: PositionOffsets }) => { + const session = useVoipSession(); + const { open: isDialerOpen } = useVoipDialer(); + + if (isVoipIncomingSession(session)) { + return <IncomingView session={session} position={position} />; + } + + if (isVoipOngoingSession(session)) { + return <OngoingView session={session} position={position} />; + } + + if (isVoipOutgoingSession(session)) { + return <OutgoingView session={session} position={position} />; + } + + if (isVoipErrorSession(session)) { + return <ErrorView session={session} position={position} />; + } + + if (isDialerOpen) { + return <DialerView position={position} />; + } + + return null; +}; + +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 new file mode 100644 index 0000000000000000000000000000000000000000..8c8df4a661ece009e6b338ad7e0cd9c97e3989e3 --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/__snapshots__/VoipPopup.spec.tsx.snap @@ -0,0 +1,1605 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders ErrorCall without crashing 1`] = ` +<body> + <div> + <span + data-focus-scope-start="true" + hidden="" + /> + <article + aria-labelledby="voipPopupTitle" + class="rcx-css-1u0qz2a" + data-testid="vc-popup-dialer" + > + <header + class="rcx-box rcx-box--full rcx-css-1bact0x" + > + <h3 + class="rcx-box rcx-box--full rcx-css-6rm93" + id="voipPopupTitle" + > + New_Call + </h3> + <button + aria-label="Close" + class="rcx-box rcx-box--full rcx-button--mini-square rcx-button--square rcx-button--icon rcx-button rcx-css-ws1yyr" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-1tg7yd0" + > +  + </i> + </button> + </header> + <section + class="rcx-box rcx-box--full" + data-testid="vc-popup-content" + > + <span + data-focus-scope-start="true" + hidden="" + /> + <div + class="rcx-box rcx-box--full rcx-css-13wyzsd" + > + <div + class="rcx-box rcx-box--full rcx-css-1gr9o58" + > + <label + class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-1nolsce" + > + <input + aria-label="Phone_number" + class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box rcx-css-1nolsce rcx-css-1uqi900" + size="1" + type="text" + value="" + /> + <span + class="rcx-box rcx-box--full rcx-input-box__addon" + > + <button + aria-label="Remove_last_character" + class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-1yuobxc" + data-testid="dial-paid-input-backspace" + disabled="" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-backspace rcx-icon rcx-css-4pvxx3" + > + [ + </i> + </button> + </span> + </label> + </div> + <div + class="rcx-box rcx-box--full rcx-css-dqa7fo rcx-css-keglju" + > + <button + aria-describedby="react-aria-description-1" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-1" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 1 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + /> + </span> + </button> + <button + aria-describedby="react-aria-description-1" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-2" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 2 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + ABC + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-1" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-3" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 3 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + DEF + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-1" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-4" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 4 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + GHI + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-1" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-5" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 5 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + JKL + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-1" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-6" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 6 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + MNO + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-1" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-7" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 7 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + PQRS + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-1" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-8" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 8 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + TUV + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-1" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-9" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 9 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + WXYZ + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-1" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-*" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + * + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + /> + </span> + </button> + <button + aria-describedby="react-aria-description-1" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-0" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 0 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + + + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-1" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-#" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + # + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + /> + </span> + </button> + </div> + </div> + <span + data-focus-scope-end="true" + hidden="" + /> + </section> + <footer + class="rcx-box rcx-box--full rcx-css-29fkf" + data-testid="vc-popup-footer" + > + <div + class="rcx-button-group rcx-button-group--align-start rcx-button-group--large" + role="group" + > + <button + class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button" + disabled="" + title="Device_settings_not_supported_by_browser" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-customize rcx-icon rcx-css-4pvxx3" + > + î€ + </i> + </button> + <button + class="rcx-box rcx-box--full rcx-button--medium rcx-button--success rcx-button rcx-button-group__item rcx-css-t3n91h" + disabled="" + type="button" + > + <span + class="rcx-button--content" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-phone rcx-icon rcx-css-1hdf9ok" + > + î¢ + </i> + Call + </span> + </button> + </div> + </footer> + </article> + <span + data-focus-scope-end="true" + hidden="" + /> + </div> + <div + id="react-aria-description-1" + style="display: none;" + > + Long_press_to_do_x + </div> +</body> +`; + +exports[`renders IncomingCall without crashing 1`] = ` +<body> + <div> + <span + data-focus-scope-start="true" + hidden="" + /> + <article + aria-labelledby="voipPopupTitle" + class="rcx-css-1u0qz2a" + data-testid="vc-popup-dialer" + > + <header + class="rcx-box rcx-box--full rcx-css-1bact0x" + > + <h3 + class="rcx-box rcx-box--full rcx-css-6rm93" + id="voipPopupTitle" + > + New_Call + </h3> + <button + aria-label="Close" + class="rcx-box rcx-box--full rcx-button--mini-square rcx-button--square rcx-button--icon rcx-button rcx-css-ws1yyr" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-1tg7yd0" + > +  + </i> + </button> + </header> + <section + class="rcx-box rcx-box--full" + data-testid="vc-popup-content" + > + <span + data-focus-scope-start="true" + hidden="" + /> + <div + class="rcx-box rcx-box--full rcx-css-13wyzsd" + > + <div + class="rcx-box rcx-box--full rcx-css-1gr9o58" + > + <label + class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-1nolsce" + > + <input + aria-label="Phone_number" + class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box rcx-css-1nolsce rcx-css-1uqi900" + size="1" + type="text" + value="" + /> + <span + class="rcx-box rcx-box--full rcx-input-box__addon" + > + <button + aria-label="Remove_last_character" + class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-1yuobxc" + data-testid="dial-paid-input-backspace" + disabled="" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-backspace rcx-icon rcx-css-4pvxx3" + > + [ + </i> + </button> + </span> + </label> + </div> + <div + class="rcx-box rcx-box--full rcx-css-dqa7fo rcx-css-keglju" + > + <button + aria-describedby="react-aria-description-2" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-1" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 1 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + /> + </span> + </button> + <button + aria-describedby="react-aria-description-2" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-2" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 2 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + ABC + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-2" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-3" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 3 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + DEF + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-2" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-4" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 4 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + GHI + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-2" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-5" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 5 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + JKL + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-2" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-6" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 6 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + MNO + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-2" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-7" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 7 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + PQRS + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-2" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-8" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 8 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + TUV + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-2" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-9" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 9 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + WXYZ + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-2" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-*" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + * + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + /> + </span> + </button> + <button + aria-describedby="react-aria-description-2" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-0" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 0 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + + + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-2" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-#" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + # + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + /> + </span> + </button> + </div> + </div> + <span + data-focus-scope-end="true" + hidden="" + /> + </section> + <footer + class="rcx-box rcx-box--full rcx-css-29fkf" + data-testid="vc-popup-footer" + > + <div + class="rcx-button-group rcx-button-group--align-start rcx-button-group--large" + role="group" + > + <button + class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button" + disabled="" + title="Device_settings_not_supported_by_browser" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-customize rcx-icon rcx-css-4pvxx3" + > + î€ + </i> + </button> + <button + class="rcx-box rcx-box--full rcx-button--medium rcx-button--success rcx-button rcx-button-group__item rcx-css-t3n91h" + disabled="" + type="button" + > + <span + class="rcx-button--content" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-phone rcx-icon rcx-css-1hdf9ok" + > + î¢ + </i> + Call + </span> + </button> + </div> + </footer> + </article> + <span + data-focus-scope-end="true" + hidden="" + /> + </div> + <div + id="react-aria-description-2" + style="display: none;" + > + Long_press_to_do_x + </div> +</body> +`; + +exports[`renders OngoingCall without crashing 1`] = ` +<body> + <div> + <span + data-focus-scope-start="true" + hidden="" + /> + <article + aria-labelledby="voipPopupTitle" + class="rcx-css-1u0qz2a" + data-testid="vc-popup-dialer" + > + <header + class="rcx-box rcx-box--full rcx-css-1bact0x" + > + <h3 + class="rcx-box rcx-box--full rcx-css-6rm93" + id="voipPopupTitle" + > + New_Call + </h3> + <button + aria-label="Close" + class="rcx-box rcx-box--full rcx-button--mini-square rcx-button--square rcx-button--icon rcx-button rcx-css-ws1yyr" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-1tg7yd0" + > +  + </i> + </button> + </header> + <section + class="rcx-box rcx-box--full" + data-testid="vc-popup-content" + > + <span + data-focus-scope-start="true" + hidden="" + /> + <div + class="rcx-box rcx-box--full rcx-css-13wyzsd" + > + <div + class="rcx-box rcx-box--full rcx-css-1gr9o58" + > + <label + class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-1nolsce" + > + <input + aria-label="Phone_number" + class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box rcx-css-1nolsce rcx-css-1uqi900" + size="1" + type="text" + value="" + /> + <span + class="rcx-box rcx-box--full rcx-input-box__addon" + > + <button + aria-label="Remove_last_character" + class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-1yuobxc" + data-testid="dial-paid-input-backspace" + disabled="" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-backspace rcx-icon rcx-css-4pvxx3" + > + [ + </i> + </button> + </span> + </label> + </div> + <div + class="rcx-box rcx-box--full rcx-css-dqa7fo rcx-css-keglju" + > + <button + aria-describedby="react-aria-description-3" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-1" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 1 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + /> + </span> + </button> + <button + aria-describedby="react-aria-description-3" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-2" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 2 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + ABC + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-3" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-3" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 3 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + DEF + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-3" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-4" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 4 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + GHI + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-3" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-5" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 5 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + JKL + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-3" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-6" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 6 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + MNO + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-3" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-7" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 7 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + PQRS + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-3" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-8" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 8 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + TUV + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-3" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-9" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 9 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + WXYZ + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-3" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-*" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + * + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + /> + </span> + </button> + <button + aria-describedby="react-aria-description-3" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-0" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 0 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + + + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-3" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-#" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + # + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + /> + </span> + </button> + </div> + </div> + <span + data-focus-scope-end="true" + hidden="" + /> + </section> + <footer + class="rcx-box rcx-box--full rcx-css-29fkf" + data-testid="vc-popup-footer" + > + <div + class="rcx-button-group rcx-button-group--align-start rcx-button-group--large" + role="group" + > + <button + class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button" + disabled="" + title="Device_settings_not_supported_by_browser" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-customize rcx-icon rcx-css-4pvxx3" + > + î€ + </i> + </button> + <button + class="rcx-box rcx-box--full rcx-button--medium rcx-button--success rcx-button rcx-button-group__item rcx-css-t3n91h" + disabled="" + type="button" + > + <span + class="rcx-button--content" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-phone rcx-icon rcx-css-1hdf9ok" + > + î¢ + </i> + Call + </span> + </button> + </div> + </footer> + </article> + <span + data-focus-scope-end="true" + hidden="" + /> + </div> + <div + id="react-aria-description-3" + style="display: none;" + > + Long_press_to_do_x + </div> +</body> +`; + +exports[`renders OutgoingCall without crashing 1`] = ` +<body> + <div> + <span + data-focus-scope-start="true" + hidden="" + /> + <article + aria-labelledby="voipPopupTitle" + class="rcx-css-1u0qz2a" + data-testid="vc-popup-dialer" + > + <header + class="rcx-box rcx-box--full rcx-css-1bact0x" + > + <h3 + class="rcx-box rcx-box--full rcx-css-6rm93" + id="voipPopupTitle" + > + New_Call + </h3> + <button + aria-label="Close" + class="rcx-box rcx-box--full rcx-button--mini-square rcx-button--square rcx-button--icon rcx-button rcx-css-ws1yyr" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-1tg7yd0" + > +  + </i> + </button> + </header> + <section + class="rcx-box rcx-box--full" + data-testid="vc-popup-content" + > + <span + data-focus-scope-start="true" + hidden="" + /> + <div + class="rcx-box rcx-box--full rcx-css-13wyzsd" + > + <div + class="rcx-box rcx-box--full rcx-css-1gr9o58" + > + <label + class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-1nolsce" + > + <input + aria-label="Phone_number" + class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box rcx-css-1nolsce rcx-css-1uqi900" + size="1" + type="text" + value="" + /> + <span + class="rcx-box rcx-box--full rcx-input-box__addon" + > + <button + aria-label="Remove_last_character" + class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-1yuobxc" + data-testid="dial-paid-input-backspace" + disabled="" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-backspace rcx-icon rcx-css-4pvxx3" + > + [ + </i> + </button> + </span> + </label> + </div> + <div + class="rcx-box rcx-box--full rcx-css-dqa7fo rcx-css-keglju" + > + <button + aria-describedby="react-aria-description-4" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-1" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 1 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + /> + </span> + </button> + <button + aria-describedby="react-aria-description-4" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-2" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 2 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + ABC + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-4" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-3" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 3 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + DEF + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-4" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-4" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 4 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + GHI + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-4" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-5" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 5 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + JKL + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-4" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-6" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 6 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + MNO + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-4" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-7" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 7 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + PQRS + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-4" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-8" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 8 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + TUV + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-4" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-9" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 9 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + WXYZ + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-4" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-*" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + * + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + /> + </span> + </button> + <button + aria-describedby="react-aria-description-4" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-0" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + 0 + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + > + + + </span> + </span> + </button> + <button + aria-describedby="react-aria-description-4" + class="rcx-box rcx-box--full rcx-button rcx-css-w0xx3d" + data-testid="dial-pad-button-#" + type="button" + > + <span + class="rcx-button--content" + > + <span + class="rcx-box rcx-box--full rcx-css-1q15p4p" + > + # + </span> + <span + aria-hidden="true" + class="rcx-box rcx-box--full rcx-css-1v117po" + /> + </span> + </button> + </div> + </div> + <span + data-focus-scope-end="true" + hidden="" + /> + </section> + <footer + class="rcx-box rcx-box--full rcx-css-29fkf" + data-testid="vc-popup-footer" + > + <div + class="rcx-button-group rcx-button-group--align-start rcx-button-group--large" + role="group" + > + <button + class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button" + disabled="" + title="Device_settings_not_supported_by_browser" + type="button" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-customize rcx-icon rcx-css-4pvxx3" + > + î€ + </i> + </button> + <button + class="rcx-box rcx-box--full rcx-button--medium rcx-button--success rcx-button rcx-button-group__item rcx-css-t3n91h" + disabled="" + type="button" + > + <span + class="rcx-button--content" + > + <i + aria-hidden="true" + class="rcx-box rcx-box--full rcx-icon--name-phone rcx-icon rcx-css-1hdf9ok" + > + î¢ + </i> + Call + </span> + </button> + </div> + </footer> + </article> + <span + data-focus-scope-end="true" + hidden="" + /> + </div> + <div + id="react-aria-description-4" + style="display: none;" + > + Long_press_to_do_x + </div> +</body> +`; diff --git a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupContainer.tsx b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupContainer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..048501d94062e236c7246b90eafcd4625484a55d --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupContainer.tsx @@ -0,0 +1,49 @@ +import { Palette } from '@rocket.chat/fuselage'; +import styled from '@rocket.chat/styled'; +import type { ReactNode } from 'react'; +import { FocusScope } from 'react-aria'; + +export type PositionOffsets = Partial<{ + top: number; + right: number; + bottom: number; + left: number; +}>; + +type ContainerProps = { + children: ReactNode; + secondary?: boolean; + position?: PositionOffsets; + ['data-testid']: string; +}; + +const Container = styled( + 'article', + ({ secondary: _secondary, position: _position, ...props }: Pick<ContainerProps, 'secondary' | 'position'>) => 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: ${(p) => (p.secondary ? Palette.surface['surface-neutral'].toString() : Palette.surface['surface-light'].toString())}; + z-index: 100; +`; + +const VoipPopupContainer = ({ children, secondary = false, position = { top: 0, left: 0 }, ...props }: ContainerProps) => ( + <FocusScope autoFocus restoreFocus> + <Container aria-labelledby='voipPopupTitle' secondary={secondary} position={position} {...props}> + {children} + </Container> + </FocusScope> +); + +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 new file mode 100644 index 0000000000000000000000000000000000000000..aecc43de074cdecc0132e732ddf5097de582f1de --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupContent.tsx @@ -0,0 +1,10 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; + +const VoipPopupContent = ({ children }: { children: ReactNode }) => ( + <Box is='section' data-testid='vc-popup-content'> + {children} + </Box> +); + +export default VoipPopupContent; diff --git a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupFooter.tsx b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupFooter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bb241136a42a872ea2ec830b82a84803c7ebb722 --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupFooter.tsx @@ -0,0 +1,10 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; + +const VoipPopupFooter = ({ children }: { children: ReactNode }) => ( + <Box is='footer' data-testid='vc-popup-footer' p={12} mbs='auto' bg='surface-light' borderRadius='0 0 4px 4px'> + {children} + </Box> +); + +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 new file mode 100644 index 0000000000000000000000000000000000000000..9c51e5ad3bc892150bee14b14a2d01c1082f783f --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupHeader.spec.tsx @@ -0,0 +1,42 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; + +import VoipPopupHeader from './VoipPopupHeader'; + +it('should render title', () => { + render(<VoipPopupHeader>voice call header title</VoipPopupHeader>, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByText('voice call header title')).toBeInTheDocument(); +}); + +it('should not render close button when onClose is not provided', () => { + render(<VoipPopupHeader />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); +}); + +it('should render close button when onClose is provided', () => { + render(<VoipPopupHeader onClose={jest.fn()} />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); +}); + +it('should call onClose when close button is clicked', () => { + const closeFn = jest.fn(); + render(<VoipPopupHeader onClose={closeFn} />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + screen.getByRole('button', { name: 'Close' }).click(); + expect(closeFn).toHaveBeenCalled(); +}); + +it('should render settings button by default', () => { + render(<VoipPopupHeader />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByRole('button', { name: /Device_settings/ })).toBeInTheDocument(); +}); + +it('should not render settings button when hideSettings is true', () => { + render(<VoipPopupHeader hideSettings>text</VoipPopupHeader>, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..51ae01ef490e05cddbbaa9360a6dae2e7815678f --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupHeader.tsx @@ -0,0 +1,34 @@ +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 ( + <Box is='header' p={12} pbe={4} display='flex' alignItems='center' justifyContent='space-between'> + {children && ( + <Box is='h3' id='voipPopupTitle' color='titles-labels' fontScale='p2' fontWeight='700'> + {children} + </Box> + )} + + {!hideSettings && ( + <Box mis={8}> + <VoipSettingsButton mini /> + </Box> + )} + + {onClose && <IconButton mini mis={8} aria-label={t('Close')} icon='cross' onClick={onClose} />} + </Box> + ); +}; +export default VoipPopupHeader; diff --git a/packages/ui-voip/src/components/VoipPopup/components/index.ts b/packages/ui-voip/src/components/VoipPopup/components/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f6bfd56b1d472b901e8545be3974b20fb710034 --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/components/index.ts @@ -0,0 +1,4 @@ +export { default as VoipPopupContainer } from './VoipPopupContainer'; +export { default as VoipPopupHeader } from './VoipPopupHeader'; +export { default as VoipPopupContent } from './VoipPopupContent'; +export { default as VoipPopupFooter } from './VoipPopupFooter'; diff --git a/packages/ui-voip/src/components/VoipPopup/index.ts b/packages/ui-voip/src/components/VoipPopup/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..753fa81fc6272e54f615245a3f0c96d68a6a9c9b --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..efe175bffad3c99fb273001a15c2d6b2ea2c1f1b --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.spec.tsx @@ -0,0 +1,47 @@ +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 })), +})); + +it('should look good', async () => { + render(<VoipDialerView />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + 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(<VoipDialerView />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + 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(<VoipDialerView />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByRole('button', { name: /Call/i })).toBeDisabled(); + screen.getByTestId(`dial-pad-button-1`).click(); + screen.getByTestId(`dial-pad-button-2`).click(); + screen.getByTestId(`dial-pad-button-3`).click(); + expect(screen.getByRole('button', { name: /Call/i })).toBeEnabled(); +}); + +it('should call methods makeCall and closeDialer when call button is clicked', async () => { + render(<VoipDialerView />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + await userEvent.type(screen.getByLabelText('Phone_number'), '123'); + screen.getByTestId(`dial-pad-button-1`).click(); + screen.getByRole('button', { name: /Call/i }).click(); + 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 new file mode 100644 index 0000000000000000000000000000000000000000..5d525cc63c9af250f033fb2a76395808c6c9012c --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx @@ -0,0 +1,49 @@ +import { Button, ButtonGroup } from '@rocket.chat/fuselage'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { VoipDialPad as DialPad, VoipSettingsButton as SettingsButton } from '../..'; +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; +}; + +const VoipDialerView = ({ position }: VoipDialerViewProps) => { + const { t } = useTranslation(); + const { makeCall, closeDialer } = useVoipAPI(); + const [number, setNumber] = useState(''); + + const handleCall = () => { + makeCall(number); + closeDialer(); + }; + + return ( + <Container secondary data-testid='vc-popup-dialer' position={position}> + <Header hideSettings onClose={closeDialer}> + {t('New_Call')} + </Header> + + <Content> + <DialPad editable value={number} onChange={(value) => setNumber(value)} /> + </Content> + + <Footer> + <ButtonGroup large> + <SettingsButton /> + <Button medium success icon='phone' disabled={!number} flexGrow={1} onClick={handleCall}> + {t('Call')} + </Button> + </ButtonGroup> + </Footer> + </Container> + ); +}; + +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 new file mode 100644 index 0000000000000000000000000000000000000000..2b7823d9141a7575822a7e2f2b6345c326720ef7 --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.spec.tsx @@ -0,0 +1,74 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, within } from '@testing-library/react'; + +import { createMockFreeSwitchExtensionDetails, createMockVoipErrorSession } from '../../../tests/mocks'; +import VoipErrorView from './VoipErrorView'; + +const wrapper = mockAppRoot().withEndpoint('GET', '/v1/voip-freeswitch.extension.getDetails', () => createMockFreeSwitchExtensionDetails()); + +it('should properly render error view', async () => { + const errorSession = createMockVoipErrorSession({ error: { status: -1, reason: '' } }); + render(<VoipErrorView session={errorSession} />, { wrapper: wrapper.build(), legacyRoot: true }); + + 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(<VoipErrorView session={errorSession} />, { wrapper: wrapper.build(), legacyRoot: true }); + + 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', () => { + const errorSession = createMockVoipErrorSession({ error: { status: -1, reason: '' } }); + render(<VoipErrorView session={errorSession} />, { wrapper: wrapper.build(), legacyRoot: true }); + + screen.getByRole('button', { name: 'End_call' }).click(); + expect(errorSession.end).toHaveBeenCalled(); +}); + +it('should properly render unknown error calls', async () => { + const session = createMockVoipErrorSession({ error: { status: -1, reason: '' } }); + render(<VoipErrorView session={session} />, { wrapper: wrapper.build(), legacyRoot: true }); + + expect(screen.getByText('Unable_to_complete_call')).toBeInTheDocument(); + screen.getByRole('button', { name: 'End_call' }).click(); + expect(session.end).toHaveBeenCalled(); +}); + +it('should properly render error for unavailable calls', async () => { + const session = createMockVoipErrorSession({ error: { status: 480, reason: '' } }); + render(<VoipErrorView session={session} />, { wrapper: wrapper.build(), legacyRoot: true }); + + expect(screen.getByText('Temporarily_unavailable')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'End_call' })).toBeEnabled(); + screen.getByRole('button', { name: 'End_call' }).click(); + expect(session.end).toHaveBeenCalled(); +}); + +it('should properly render error for busy calls', async () => { + const session = createMockVoipErrorSession({ error: { status: 486, reason: '' } }); + render(<VoipErrorView session={session} />, { wrapper: wrapper.build(), legacyRoot: true }); + + expect(screen.getByText('Caller_is_busy')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'End_call' })).toBeEnabled(); + screen.getByRole('button', { name: 'End_call' }).click(); + expect(session.end).toHaveBeenCalled(); +}); + +it('should properly render error for terminated calls', async () => { + const session = createMockVoipErrorSession({ error: { status: 487, reason: '' } }); + render(<VoipErrorView session={session} />, { wrapper: wrapper.build(), legacyRoot: true }); + + expect(screen.getByText('Call_terminated')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'End_call' })).toBeEnabled(); + screen.getByRole('button', { name: 'End_call' }).click(); + 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 new file mode 100644 index 0000000000000000000000000000000000000000..9c6dbd32b346e3b58098313b0de94ecde6384355 --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.tsx @@ -0,0 +1,57 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import { useMemo } 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; +}; + +const VoipErrorView = ({ session, position }: VoipErrorViewProps) => { + const { t } = useTranslation(); + const contactData = useVoipContactId({ session }); + + const { status } = session.error; + + const title = useMemo(() => { + switch (status) { + 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'); + } + }, [status, t]); + + return ( + <Container data-testid='vc-popup-error' position={position}> + <Header hideSettings> + <Box fontScale='p2' color='danger' fontWeight={700}> + <Icon name='warning' size={16} /> {title} + </Box> + </Header> + + <Content> + <CallContactId {...contactData} /> + </Content> + + <Footer> + <Actions onEndCall={session.end} /> + </Footer> + </Container> + ); +}; + +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 new file mode 100644 index 0000000000000000000000000000000000000000..9b364d51178e9a68f17c7af4c3c1ad78e9b7e2a7 --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipIncomingView.spec.tsx @@ -0,0 +1,38 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, within } from '@testing-library/react'; + +import { createMockFreeSwitchExtensionDetails, createMockVoipIncomingSession } from '../../../tests/mocks'; +import VoipIncomingView from './VoipIncomingView'; + +const wrapper = mockAppRoot().withEndpoint('GET', '/v1/voip-freeswitch.extension.getDetails', () => createMockFreeSwitchExtensionDetails()); + +const incomingSession = createMockVoipIncomingSession(); + +it('should properly render incoming view', async () => { + render(<VoipIncomingView session={incomingSession} />, { wrapper: wrapper.build(), legacyRoot: true }); + + 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(<VoipIncomingView session={incomingSession} />, { wrapper: wrapper.build(), legacyRoot: true }); + + 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', () => { + render(<VoipIncomingView session={incomingSession} />, { wrapper: wrapper.build(), legacyRoot: true }); + + screen.getByRole('button', { name: 'Decline' }).click(); + screen.getByRole('button', { name: 'Accept' }).click(); + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..39655bf1ce4c2ff176c4a04b0ec248fdaf056a05 --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipIncomingView.tsx @@ -0,0 +1,36 @@ +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; +}; + +const VoipIncomingView = ({ session, position }: VoipIncomingViewProps) => { + const { t } = useTranslation(); + const contactData = useVoipContactId({ session }); + + return ( + <Container data-testid='vc-popup-incoming' position={position}> + <Header>{`${session.transferedBy ? t('Incoming_call_transfer') : t('Incoming_call')}...`}</Header> + + <Content> + <CallContactId {...contactData} /> + </Content> + + <Footer> + <Actions onAccept={session.accept} onDecline={session.end} /> + </Footer> + </Container> + ); +}; + +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 new file mode 100644 index 0000000000000000000000000000000000000000..afe589ff4a51919e478ead9802ac4dc8970ddf2f --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipOngoingView.spec.tsx @@ -0,0 +1,62 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, within } from '@testing-library/react'; + +import { createMockFreeSwitchExtensionDetails, createMockVoipOngoingSession } from '../../../tests/mocks'; +import VoipOngoingView from './VoipOngoingView'; + +const wrapper = mockAppRoot().withEndpoint('GET', '/v1/voip-freeswitch.extension.getDetails', () => createMockFreeSwitchExtensionDetails()); + +const ongoingSession = createMockVoipOngoingSession(); + +it('should properly render ongoing view', async () => { + render(<VoipOngoingView session={ongoingSession} />, { wrapper: wrapper.build(), legacyRoot: true }); + + 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(<VoipOngoingView session={ongoingSession} />, { wrapper: wrapper.build(), legacyRoot: true }); + + 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(<VoipOngoingView session={ongoingSession} />, { wrapper: wrapper.build(), legacyRoot: true }); + + 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', () => { + render(<VoipOngoingView session={ongoingSession} />, { wrapper: wrapper.build(), legacyRoot: true }); + + screen.getByRole('button', { name: 'Turn_off_microphone' }).click(); + expect(ongoingSession.mute).toHaveBeenCalled(); + + screen.getByRole('button', { name: 'Hold' }).click(); + expect(ongoingSession.hold).toHaveBeenCalled(); + + screen.getByRole('button', { name: 'Open_Dialpad' }).click(); + screen.getByTestId('dial-pad-button-1').click(); + expect(screen.getByRole('textbox', { name: 'Phone_number' })).toHaveValue('1'); + expect(ongoingSession.dtmf).toHaveBeenCalledWith('1'); + + expect(screen.getByRole('button', { name: 'End_call' })).toBeEnabled(); + screen.getByRole('button', { name: 'End_call' }).click(); + 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 new file mode 100644 index 0000000000000000000000000000000000000000..4ab37883d8294245b43678098a0470d30f8dca1a --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipOngoingView.tsx @@ -0,0 +1,68 @@ +import { useState } 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; +}; + +const VoipOngoingView = ({ session, position }: VoipOngoingViewProps) => { + 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 ( + <Container secondary data-testid='vc-popup-ongoing' position={position}> + <Header> + <Timer /> + </Header> + + <Content> + <Status isMuted={session.isMuted} isHeld={session.isHeld} /> + + <CallContactId {...contactData} /> + + {isDialPadOpen && <DialPad value={dtmfValue} longPress={false} onChange={handleDTMF} />} + </Content> + + <Footer> + <Actions + isMuted={session.isMuted} + isHeld={session.isHeld} + isDTMFActive={isDialPadOpen} + onMute={session.mute} + onHold={session.hold} + onEndCall={session.end} + onTransfer={startTransfer} + onDTMF={() => setDialerOpen(!isDialPadOpen)} + /> + </Footer> + </Container> + ); +}; + +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 new file mode 100644 index 0000000000000000000000000000000000000000..2f5f3b5840f463204695fce4ffaaaf25b4670f4b --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipOutgoingView.spec.tsx @@ -0,0 +1,35 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, within } from '@testing-library/react'; + +import { createMockFreeSwitchExtensionDetails, createMockVoipOutgoingSession } from '../../../tests/mocks'; +import VoipOutgoingView from './VoipOutgoingView'; + +const wrapper = mockAppRoot().withEndpoint('GET', '/v1/voip-freeswitch.extension.getDetails', () => createMockFreeSwitchExtensionDetails()); + +const outgoingSession = createMockVoipOutgoingSession(); + +it('should properly render outgoing view', async () => { + render(<VoipOutgoingView session={outgoingSession} />, { wrapper: wrapper.build(), legacyRoot: true }); + + 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(<VoipOutgoingView session={outgoingSession} />, { wrapper: wrapper.build(), legacyRoot: true }); + + 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', () => { + render(<VoipOutgoingView session={outgoingSession} />, { wrapper: wrapper.build(), legacyRoot: true }); + + screen.getByRole('button', { name: 'End_call' }).click(); + 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 new file mode 100644 index 0000000000000000000000000000000000000000..9fb43e783da70eb9c077370b790f25abc9381b84 --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipOutgoingView.tsx @@ -0,0 +1,36 @@ +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; +}; + +const VoipOutgoingView = ({ session, position }: VoipOutgoingViewProps) => { + const { t } = useTranslation(); + const contactData = useVoipContactId({ session }); + + return ( + <Container data-testid='vc-popup-outgoing' position={position}> + <Header>{`${t('Calling')}...`}</Header> + + <Content> + <CallContactId {...contactData} /> + </Content> + + <Footer> + <Actions onEndCall={session.end} /> + </Footer> + </Container> + ); +}; + +export default VoipOutgoingView; diff --git a/packages/ui-voip/src/components/VoipPopup/views/index.ts b/packages/ui-voip/src/components/VoipPopup/views/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f9f47cfb31dd3e15283e52e53a9117e93c7b8bd --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopup/views/index.ts @@ -0,0 +1,5 @@ +export { default as DialerView } from './VoipDialerView'; +export { default as ErrorView } from './VoipErrorView'; +export { default as VoipIncomingView } from './VoipIncomingView'; +export { default as VoipOutgoingView } from './VoipOutgoingView'; +export { default as VoipOngoingView } from './VoipOngoingView'; diff --git a/packages/ui-voip/src/components/VoipPopupPortal/VoipPopupPortal.tsx b/packages/ui-voip/src/components/VoipPopupPortal/VoipPopupPortal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d0f3c015e51bf745e2ccfad1109491ec13998bbb --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopupPortal/VoipPopupPortal.tsx @@ -0,0 +1,15 @@ +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 <AnchorPortal id={voipAnchorId}>{children}</AnchorPortal>; +}; + +export default memo(VoipPopupPortal); diff --git a/packages/ui-voip/src/components/VoipPopupPortal/index.ts b/packages/ui-voip/src/components/VoipPopupPortal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..47db7440804f136ab5c7578fd56e34dc5a3b49f8 --- /dev/null +++ b/packages/ui-voip/src/components/VoipPopupPortal/index.ts @@ -0,0 +1 @@ +export { default } from './VoipPopupPortal'; diff --git a/packages/ui-voip/src/components/VoipSettingsButton/VoipSettingsButton.tsx b/packages/ui-voip/src/components/VoipSettingsButton/VoipSettingsButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0c3db9e484335aa730cfe203ef080ded85ba352d --- /dev/null +++ b/packages/ui-voip/src/components/VoipSettingsButton/VoipSettingsButton.tsx @@ -0,0 +1,34 @@ +/* eslint-disable react/no-multi-comp */ +import { IconButton } from '@rocket.chat/fuselage'; +import { GenericMenu } from '@rocket.chat/ui-client'; +import type { ComponentProps, Ref } from 'react'; +import { forwardRef } from 'react'; + +import { useVoipDeviceSettings } from './hooks/useVoipDeviceSettings'; + +const CustomizeButton = forwardRef(function CustomizeButton( + { mini, ...props }: ComponentProps<typeof IconButton>, + ref: Ref<HTMLButtonElement>, +) { + const size = mini ? 24 : 32; + return <IconButton {...props} ref={ref} icon='customize' mini width={size} height={size} />; +}); + +const VoipSettingsButton = ({ mini = false }: { mini?: boolean }) => { + const menu = useVoipDeviceSettings(); + + return ( + <GenericMenu + is={CustomizeButton} + title={menu.title} + disabled={menu.disabled} + sections={menu.sections} + selectionMode='multiple' + placement='top-end' + icon='customize' + mini={mini} + /> + ); +}; + +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 new file mode 100644 index 0000000000000000000000000000000000000000..17da9f5d8142dba43d7d95a1c83ae78e3f024b68 --- /dev/null +++ b/packages/ui-voip/src/components/VoipSettingsButton/hooks/useVoipDeviceSettings.spec.tsx @@ -0,0 +1,46 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { DeviceContext, DeviceContextValue } from '@rocket.chat/ui-contexts'; +import { renderHook } from '@testing-library/react'; + +import { useVoipDeviceSettings } from './useVoipDeviceSettings'; + +let mockDeviceContextValue = { + enabled: true, + selectedAudioOutputDevice: undefined, + selectedAudioInputDevice: undefined, + availableAudioOutputDevices: [], + availableAudioInputDevices: [], + setAudioOutputDevice: () => undefined, + setAudioInputDevice: () => undefined, +} as unknown as DeviceContextValue; + +it('should be disabled when there are no devices', () => { + const { result } = renderHook(() => useVoipDeviceSettings(), { + wrapper: mockAppRoot() + .wrap((children) => <DeviceContext.Provider value={mockDeviceContextValue}>{children}</DeviceContext.Provider>) + .build(), + legacyRoot: true, + }); + + expect(result.current.title).toBe('Device_settings_not_supported_by_browser'); + expect(result.current.disabled).toBeTruthy(); +}); + +it('should be enabled when there are devices', () => { + mockDeviceContextValue = { + ...mockDeviceContextValue, + + availableAudioOutputDevices: [{ label: '' }], + availableAudioInputDevices: [{ label: '' }], + } as unknown as DeviceContextValue; + + const { result } = renderHook(() => useVoipDeviceSettings(), { + wrapper: mockAppRoot() + .wrap((children) => <DeviceContext.Provider value={mockDeviceContextValue}>{children}</DeviceContext.Provider>) + .build(), + legacyRoot: true, + }); + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..2ac43f66fe88bc54c7fe8221efe5bdbf7bcd7a0e --- /dev/null +++ b/packages/ui-voip/src/components/VoipSettingsButton/hooks/useVoipDeviceSettings.tsx @@ -0,0 +1,75 @@ +import { Box, RadioButton } from '@rocket.chat/fuselage'; +import { 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'; + +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<GenericMenuItemProps>((device) => ({ + id: device.id, + content: ( + <Box is='span' title={device.label} fontSize={14}> + {device.label} + </Box> + ), + addon: <RadioButton onChange={() => changeInputDevice.mutate(device)} checked={device.id === selectedAudioDevices?.audioInput?.id} />, + })) || []; + + const availableOutputDevice = + availableDevices?.audioOutput?.map<GenericMenuItemProps>((device) => ({ + id: device.id, + content: ( + <Box is='span' title={device.label} fontSize={14}> + {device.label} + </Box> + ), + addon: ( + <RadioButton onChange={() => changeOutputDevice.mutate(device)} checked={device.id === selectedAudioDevices?.audioOutput?.id} /> + ), + onClick(e?: MouseEvent<HTMLElement>) { + e?.preventDefault(); + e?.stopPropagation(); + }, + })) || []; + + const micSection = { + title: t('Microphone'), + items: availableInputDevice, + }; + + const speakerSection = { + title: t('Speaker'), + items: availableOutputDevice, + }; + + const disabled = !micSection.items.length || !speakerSection.items.length; + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..f99032fedee598dcbd6d832e93090e5d966f5e87 --- /dev/null +++ b/packages/ui-voip/src/components/VoipSettingsButton/index.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..f81d35dc8cfccb040a647aa4df3bb1d816a22654 --- /dev/null +++ b/packages/ui-voip/src/components/VoipStatus/VoipStatus.spec.tsx @@ -0,0 +1,32 @@ +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(<VoipStatus isHeld={false} isMuted={false} />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.queryByText('On_Hold')).not.toBeInTheDocument(); + expect(screen.queryByText('Muted')).not.toBeInTheDocument(); +}); + +it('should display on hold status', () => { + render(<VoipStatus isHeld isMuted={false} />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.getByText('On_Hold')).toBeInTheDocument(); + expect(screen.queryByText('Muted')).not.toBeInTheDocument(); +}); + +it('should display muted status', () => { + render(<VoipStatus isHeld={false} isMuted />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + expect(screen.queryByText('On_Hold')).not.toBeInTheDocument(); + expect(screen.getByText('Muted')).toBeInTheDocument(); +}); + +it('should display all statuses', () => { + render(<VoipStatus isHeld isMuted />, { wrapper: mockAppRoot().build(), legacyRoot: true }); + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..dc466bdf0e6be17a7638324a10faa8c2f3404939 --- /dev/null +++ b/packages/ui-voip/src/components/VoipStatus/VoipStatus.tsx @@ -0,0 +1,28 @@ +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 ( + <Box fontScale='p2' display='flex' justifyContent='space-between' paddingInline={12} pb={4}> + {isHeld && ( + <Box is='span' color='default'> + {t('On_Hold')} + </Box> + )} + + {isMuted && ( + <Box is='span' color='status-font-on-warning'> + {t('Muted')} + </Box> + )} + </Box> + ); +}; + +export default VoipStatus; diff --git a/packages/ui-voip/src/components/VoipStatus/index.ts b/packages/ui-voip/src/components/VoipStatus/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..91d89ce79241b9959241aac3755d9ea6b68aa9c2 --- /dev/null +++ b/packages/ui-voip/src/components/VoipStatus/index.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..12a01e9b33bfcb1a7797630969e3e3b5334ef833 --- /dev/null +++ b/packages/ui-voip/src/components/VoipTimer/VoipTimer.spec.tsx @@ -0,0 +1,37 @@ +import { act, render, screen } from '@testing-library/react'; + +import VoipTimer from './VoipTimer'; + +describe('VoipTimer', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('should display the initial time correctly', () => { + render(<VoipTimer />, { legacyRoot: true }); + + expect(screen.getByText('00:00')).toBeInTheDocument(); + }); + + it('should update the time after a few seconds', () => { + render(<VoipTimer />, { legacyRoot: true }); + + 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(<VoipTimer startAt={startTime} />, { legacyRoot: true }); + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..e90eeaee510f3218233e46ca6f110fa17eae9972 --- /dev/null +++ b/packages/ui-voip/src/components/VoipTimer/VoipTimer.tsx @@ -0,0 +1,35 @@ +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 ( + <time aria-hidden> + {hours !== '00' ? `${hours}:` : ''} + {minutes}:{seconds} + </time> + ); +}; + +export default VoipTimer; diff --git a/packages/ui-voip/src/components/VoipTimer/index.ts b/packages/ui-voip/src/components/VoipTimer/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b7e4328197090b813dd6e639174f39a4d21756a --- /dev/null +++ b/packages/ui-voip/src/components/VoipTimer/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..09c8037cd9f9533cac897c578beb3421dd382c37 --- /dev/null +++ b/packages/ui-voip/src/components/VoipTransferModal/VoipTransferModal.spec.tsx @@ -0,0 +1,94 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import VoipTransferModal from '.'; + +it('should be able to select transfer target', async () => { + const confirmFn = jest.fn(); + render(<VoipTransferModal extension='1000' onConfirm={confirmFn} onCancel={() => 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(), + legacyRoot: true, + }); + const hangUpAnTransferButton = screen.getByRole('button', { name: 'Hang_up_and_transfer_call' }); + + expect(hangUpAnTransferButton).toBeDisabled(); + + screen.getByLabelText('Transfer_to').focus(); + const userOption = await screen.findByRole('option', { name: 'Jane Doe' }); + await userEvent.click(userOption); + + expect(hangUpAnTransferButton).toBeEnabled(); + hangUpAnTransferButton.click(); + + expect(confirmFn).toHaveBeenCalledWith({ extension: '1011', name: 'Jane Doe' }); +}); + +it('should call onCancel when Cancel is clicked', () => { + const confirmFn = jest.fn(); + const cancelFn = jest.fn(); + render(<VoipTransferModal extension='1000' onConfirm={confirmFn} onCancel={cancelFn} />, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + + screen.getByText('Cancel').click(); + + expect(cancelFn).toHaveBeenCalled(); +}); + +it('should call onCancel when X is clicked', () => { + const confirmFn = jest.fn(); + const cancelFn = jest.fn(); + render(<VoipTransferModal extension='1000' onConfirm={confirmFn} onCancel={cancelFn} />, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + + screen.getByLabelText('Close').click(); + + expect(cancelFn).toHaveBeenCalled(); +}); diff --git a/packages/ui-voip/src/components/VoipTransferModal/VoipTransferModal.tsx b/packages/ui-voip/src/components/VoipTransferModal/VoipTransferModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f1de589c82b43d7e7c899f607488dff5637f5df4 --- /dev/null +++ b/packages/ui-voip/src/components/VoipTransferModal/VoipTransferModal.tsx @@ -0,0 +1,82 @@ +import { Button, Field, FieldHint, FieldLabel, FieldRow, Modal } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { UserAutoComplete } from '@rocket.chat/ui-client'; +import { useEndpoint, useUser } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import { 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 = useUniqueId(); + const modalId = useUniqueId(); + + const getUserInfo = useEndpoint('GET', '/v1/users.info'); + const { data: targetUser, isInitialLoading: isTargetInfoLoading } = useQuery( + ['/v1/users.info', username], + () => getUserInfo({ username }), + { + enabled: Boolean(username), + select: (data) => data?.user || {}, + }, + ); + + const handleConfirm = () => { + if (!targetUser?.freeSwitchExtension) { + return; + } + + onConfirm({ extension: targetUser.freeSwitchExtension, name: targetUser.name }); + }; + + return ( + <Modal open aria-labelledby={modalId}> + <Modal.Header> + <Modal.Icon color='danger' name='modal-warning' /> + <Modal.Title id={modalId}>{t('Transfer_call')}</Modal.Title> + <Modal.Close aria-label={t('Close')} onClick={onCancel} /> + </Modal.Header> + <Modal.Content> + <Field> + <FieldLabel htmlFor={transferToId}>{t('Transfer_to')}</FieldLabel> + <FieldRow> + <UserAutoComplete + id={transferToId} + value={username} + aria-describedby={`${transferToId}-hint`} + data-testid='vc-input-transfer-to' + onChange={(target) => setTransferTo(target as string)} + multiple={false} + conditions={{ + freeSwitchExtension: { $exists: true, $ne: extension }, + username: { $ne: user?.username }, + }} + /> + </FieldRow> + <FieldHint id={`${transferToId}-hint`}>{t('Select_someone_to_transfer_the_call_to')}</FieldHint> + </Field> + </Modal.Content> + <Modal.Footer> + <Modal.FooterControllers> + <Button data-testid='vc-button-cancel' secondary onClick={onCancel}> + {t('Cancel')} + </Button> + <Button danger onClick={handleConfirm} disabled={!targetUser?.freeSwitchExtension} loading={isLoading || isTargetInfoLoading}> + {t('Hang_up_and_transfer_call')} + </Button> + </Modal.FooterControllers> + </Modal.Footer> + </Modal> + ); +}; + +export default VoipTransferModal; diff --git a/packages/ui-voip/src/components/VoipTransferModal/index.ts b/packages/ui-voip/src/components/VoipTransferModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a38c21026718e591e0e385e03f5617608e49b4db --- /dev/null +++ b/packages/ui-voip/src/components/VoipTransferModal/index.ts @@ -0,0 +1 @@ +export { default } from './VoipTransferModal'; diff --git a/packages/ui-voip/src/components/index.ts b/packages/ui-voip/src/components/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1593c0b9aa5b7de693f3c06d820c5f5f79747183 --- /dev/null +++ b/packages/ui-voip/src/components/index.ts @@ -0,0 +1,7 @@ +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'; diff --git a/packages/ui-voip/src/contexts/VoipContext.tsx b/packages/ui-voip/src/contexts/VoipContext.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fd7700d54da54afd227c2a3d18556ca08fde0066 --- /dev/null +++ b/packages/ui-voip/src/contexts/VoipContext.tsx @@ -0,0 +1,44 @@ +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<void>; + changeAudioInputDevice: (selectedAudioDevices: Device) => Promise<void>; +}; + +export type VoipContextEnabled = { + isEnabled: true; + voipClient: VoIPClient | null; + error?: null; + changeAudioOutputDevice: (selectedAudioDevices: Device) => Promise<void>; + changeAudioInputDevice: (selectedAudioDevices: Device) => Promise<void>; +}; + +export type VoipContextReady = { + isEnabled: true; + voipClient: VoIPClient; + error: null; + changeAudioOutputDevice: (selectedAudioDevices: Device) => Promise<void>; + changeAudioInputDevice: (selectedAudioDevices: Device) => Promise<void>; +}; + +export type VoipContextValue = VoipContextDisabled | VoipContextEnabled | VoipContextReady | VoipContextError; + +export const isVoipContextReady = (context: VoipContextValue): context is VoipContextReady => + context.isEnabled && context.voipClient !== null; + +export const VoipContext = createContext<VoipContextValue>({ + isEnabled: false, + voipClient: null, +}); diff --git a/packages/ui-voip/src/definitions/IceServer.ts b/packages/ui-voip/src/definitions/IceServer.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f13d16e56fa05855b794908f8d833c58682d16a --- /dev/null +++ b/packages/ui-voip/src/definitions/IceServer.ts @@ -0,0 +1,5 @@ +export type IceServer = { + urls: string; + username?: string; + credential?: string; +}; diff --git a/packages/ui-voip/src/definitions/VoipSession.ts b/packages/ui-voip/src/definitions/VoipSession.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4bbd5f728ae77cc785eb06b454dad9f9f40336e --- /dev/null +++ b/packages/ui-voip/src/definitions/VoipSession.ts @@ -0,0 +1,69 @@ +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<void>; + 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<void>; +}; + +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 new file mode 100644 index 0000000000000000000000000000000000000000..b1d869534a0ef3dc8e9faadf099b59e1d5c6064a --- /dev/null +++ b/packages/ui-voip/src/definitions/index.ts @@ -0,0 +1,2 @@ +export * from './VoipSession'; +export * from './IceServer'; diff --git a/packages/ui-voip/src/hooks/index.ts b/packages/ui-voip/src/hooks/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..723c5b5fff5dfd26616ff62a26d75b9818f0759b --- /dev/null +++ b/packages/ui-voip/src/hooks/index.ts @@ -0,0 +1,6 @@ +export * from './useVoip'; +export * from './useVoipAPI'; +export * from './useVoipClient'; +export * from './useVoipDialer'; +export * from './useVoipSession'; +export * from './useVoipState'; diff --git a/packages/ui-voip/src/hooks/useVoip.tsx b/packages/ui-voip/src/hooks/useVoip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1d54d4446117b2126fe95d258710ef7d538f997b --- /dev/null +++ b/packages/ui-voip/src/hooks/useVoip.tsx @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..ca645f3d08fec3ca94e4ba7356f194f5ba3a3124 --- /dev/null +++ b/packages/ui-voip/src/hooks/useVoipAPI.tsx @@ -0,0 +1,52 @@ +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<void>; + unregister(): Promise<void>; + openDialer(): void; + closeDialer(): void; + transferCall(calleeURL: string): Promise<void>; + 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, + } 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, + }; + }, [context]); +}; diff --git a/packages/ui-voip/src/hooks/useVoipClient.tsx b/packages/ui-voip/src/hooks/useVoipClient.tsx new file mode 100644 index 0000000000000000000000000000000000000000..14ff278ca022df18b118d534e5aff589692a1dc1 --- /dev/null +++ b/packages/ui-voip/src/hooks/useVoipClient.tsx @@ -0,0 +1,89 @@ +import { useUser, useSetting, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useRef } from 'react'; + +import VoipClient from '../lib/VoipClient'; +import { useWebRtcServers } from './useWebRtcServers'; + +type VoipClientParams = { + autoRegister?: boolean; +}; + +type VoipClientResult = { + voipClient: VoipClient | null; + error: Error | null; +}; + +export const useVoipClient = ({ autoRegister = true }: VoipClientParams): VoipClientResult => { + const { _id: userId } = useUser() || {}; + const isVoipEnabled = useSetting<boolean>('VoIP_TeamCollab_Enabled'); + const voipClientRef = useRef<VoipClient | null>(null); + + const getRegistrationInfo = useEndpoint('GET', '/v1/voip-freeswitch.extension.getRegistrationInfoByUserId'); + + const iceServers = useWebRtcServers(); + + const { data: voipClient, error } = useQuery<VoipClient | null, Error>( + ['voip-client', isVoipEnabled, userId, iceServers], + async () => { + if (voipClientRef.current) { + voipClientRef.current.clear(); + } + + if (!userId) { + throw Error('User_not_found'); + } + + const registrationInfo = await getRegistrationInfo({ userId }) + .then((registration) => { + if (!registration) { + throw Error(); + } + + return registration; + }) + .catch(() => { + throw Error('Registration_information_not_found'); + }); + + const { + extension: { extension }, + credentials: { websocketPath, password }, + } = registrationInfo; + + if (!extension) { + throw Error('User_extension_not_found'); + } + + 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 + }; + + const voipClient = await VoipClient.create(config); + + if (autoRegister) { + voipClient.register(); + } + + return voipClient; + }, + { + initialData: null, + enabled: !!userId, + }, + ); + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..4ef799ec505e729e788977ba0b6f2a7e316a8ad4 --- /dev/null +++ b/packages/ui-voip/src/hooks/useVoipContactId.tsx @@ -0,0 +1,25 @@ +import type { VoipSession } from '../definitions'; +import { useVoipExtensionDetails } from './useVoipExtensionDetails'; + +export const useVoipContactId = ({ session, transferEnabled = true }: { session: VoipSession; transferEnabled?: boolean }) => { + const { data: contact, isInitialLoading: isLoading } = useVoipExtensionDetails({ extension: session.contact.id }); + const { data: transferedByContact } = useVoipExtensionDetails({ + extension: session.transferedBy?.id, + enabled: transferEnabled, + }); + + const getContactName = (data: ReturnType<typeof useVoipExtensionDetails>['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 new file mode 100644 index 0000000000000000000000000000000000000000..0714badacb55d6af9b13795d8eb4843e078ae56d --- /dev/null +++ b/packages/ui-voip/src/hooks/useVoipDialer.tsx @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..e412959ff923dadbe5814ee81de8a40f519f09b8 --- /dev/null +++ b/packages/ui-voip/src/hooks/useVoipEffect.tsx @@ -0,0 +1,30 @@ +import { useContext, useMemo, useRef } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; + +import { VoipContext } from '../contexts/VoipContext'; +import type VoIPClient from '../lib/VoipClient'; + +export const useVoipEffect = <T,>(transform: (voipClient: VoIPClient) => T, initialValue: T) => { + const { voipClient } = useContext(VoipContext); + const initValue = useRef<T>(initialValue); + const transformFn = useRef(transform); + + const [subscribe, getSnapshot] = useMemo(() => { + let state: T = initValue.current; + + const getSnapshot = (): T => state; + const subscribe = (cb: () => void) => { + if (!voipClient) return () => undefined; + + state = transformFn.current(voipClient); + return voipClient.on('stateChanged', (): void => { + state = transformFn.current(voipClient); + cb(); + }); + }; + + return [subscribe, getSnapshot]; + }, [voipClient]); + + return useSyncExternalStore(subscribe, getSnapshot); +}; diff --git a/packages/ui-voip/src/hooks/useVoipEvent.tsx b/packages/ui-voip/src/hooks/useVoipEvent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d78726de1c1dbc704d8470b2e7171e93d9161f92 --- /dev/null +++ b/packages/ui-voip/src/hooks/useVoipEvent.tsx @@ -0,0 +1,28 @@ +import { useContext, useMemo, useRef } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; + +import { VoipContext } from '../contexts/VoipContext'; +import type { VoipEvents } from '../lib/VoipClient'; + +export const useVoipEvent = <E extends keyof VoipEvents>(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 new file mode 100644 index 0000000000000000000000000000000000000000..d106ae2842aa5ee79803daf4af1782e9f315fa59 --- /dev/null +++ b/packages/ui-voip/src/hooks/useVoipExtensionDetails.tsx @@ -0,0 +1,20 @@ +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( + ['voip', 'voip-extension-details', extension, getContactDetails], + () => getContactDetails({ extension: extension as string }), + { + enabled: isEnabled, + onError: () => undefined, + }, + ); + + return { + data: isEnabled ? data : undefined, + ...result, + }; +}; diff --git a/packages/ui-voip/src/hooks/useVoipSession.tsx b/packages/ui-voip/src/hooks/useVoipSession.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9ba0874f6d10ae8ffc3c2e27db5e1a39a6759077 --- /dev/null +++ b/packages/ui-voip/src/hooks/useVoipSession.tsx @@ -0,0 +1,6 @@ +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/useVoipSounds.ts b/packages/ui-voip/src/hooks/useVoipSounds.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ba847cae8d2e86235b80da25941880adaa7c4e7 --- /dev/null +++ b/packages/ui-voip/src/hooks/useVoipSounds.ts @@ -0,0 +1,26 @@ +import { useCustomSound, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +type VoipSound = 'telephone' | 'outbound-call-ringing' | 'call-ended'; + +export const useVoipSounds = () => { + const { play, pause } = useCustomSound(); + const audioVolume = useUserPreference<number>('notificationsSoundVolume', 100) || 100; + + return useMemo( + () => ({ + play: (soundId: VoipSound, loop = true) => { + play(soundId, { + volume: Number((audioVolume / 100).toPrecision(2)), + loop, + }); + }, + stop: (soundId: VoipSound) => pause(soundId), + stopAll: () => { + pause('telephone'); + pause('outbound-call-ringing'); + }, + }), + [play, pause, audioVolume], + ); +}; diff --git a/packages/ui-voip/src/hooks/useVoipState.tsx b/packages/ui-voip/src/hooks/useVoipState.tsx new file mode 100644 index 0000000000000000000000000000000000000000..66e1ea7538a069b3a529f9d7e0dae0b4a9b84d0a --- /dev/null +++ b/packages/ui-voip/src/hooks/useVoipState.tsx @@ -0,0 +1,45 @@ +import { useContext, useMemo } from 'react'; + +import { VoipContext } from '../contexts/VoipContext'; +import { useVoipEffect } from './useVoipEffect'; + +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; +}; + +const DEFAULT_STATE = { + isRegistered: false, + isReady: false, + isInCall: false, + isOnline: false, + isIncoming: false, + isOngoing: false, + isOutgoing: false, + isError: 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 new file mode 100644 index 0000000000000000000000000000000000000000..df15e400ca2742a1d57b457befed2595c4a9c955 --- /dev/null +++ b/packages/ui-voip/src/hooks/useVoipTransferModal.tsx @@ -0,0 +1,51 @@ +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( + <VoipTransferModal + extension={session.contact.id} + isLoading={handleTransfer.isLoading} + onCancel={() => setModal(null)} + onConfirm={handleTransfer.mutate} + />, + ); + }, [handleTransfer.isLoading, handleTransfer.mutate, session, setModal]); + + return { startTransfer, cancelTransfer: close }; +}; diff --git a/packages/ui-voip/src/hooks/useWebRtcServers.ts b/packages/ui-voip/src/hooks/useWebRtcServers.ts new file mode 100644 index 0000000000000000000000000000000000000000..9753098c0a65931c26582264a60f847af3afd956 --- /dev/null +++ b/packages/ui-voip/src/hooks/useWebRtcServers.ts @@ -0,0 +1,16 @@ +import { useSetting } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import type { IceServer } from '../definitions'; +import { parseStringToIceServers } from '../utils/parseStringToIceServers'; + +export const useWebRtcServers = (): IceServer[] => { + const servers = useSetting('WebRTC_Servers'); + + return useMemo(() => { + if (typeof servers !== 'string' || !servers.trim()) { + return []; + } + return parseStringToIceServers(servers); + }, [servers]); +}; diff --git a/packages/ui-voip/src/index.ts b/packages/ui-voip/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebf30176a7256d2094bed541966149196cd4b559 --- /dev/null +++ b/packages/ui-voip/src/index.ts @@ -0,0 +1,4 @@ +export { default as VoipProvider } from './providers/VoipProvider'; +export * from './definitions/VoipSession'; +export * from './hooks'; +export * from './components'; diff --git a/packages/ui-voip/src/lib/LocalStream.ts b/packages/ui-voip/src/lib/LocalStream.ts new file mode 100644 index 0000000000000000000000000000000000000000..15acf7a531e284a5b74808e84d86d36a66749aea --- /dev/null +++ b/packages/ui-voip/src/lib/LocalStream.ts @@ -0,0 +1,106 @@ +/** + * 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 { + static async requestNewStream(constraints: MediaStreamConstraints, session: Session): Promise<MediaStream | undefined> { + 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<boolean> { + 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 new file mode 100644 index 0000000000000000000000000000000000000000..b870cb2bae4f9cea21f13a89d6d233edd59d5372 --- /dev/null +++ b/packages/ui-voip/src/lib/RemoteStream.ts @@ -0,0 +1,75 @@ +/** + * 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 new file mode 100644 index 0000000000000000000000000000000000000000..8473b8558db2a7cb1c7bfef37700d1309d14fa83 --- /dev/null +++ b/packages/ui-voip/src/lib/Stream.ts @@ -0,0 +1,67 @@ +/** + * 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 new file mode 100644 index 0000000000000000000000000000000000000000..b96adaece96829b5246c0d491c4abdfb1052c311 --- /dev/null +++ b/packages/ui-voip/src/lib/VoipClient.ts @@ -0,0 +1,868 @@ +import type { IMediaStreamRenderer, SignalingSocketEvents, VoipEvents as CoreVoipEvents } from '@rocket.chat/core-typings'; +import { type VoIPUserConfiguration } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; +import type { InvitationAcceptOptions, Message, Referral, Session, SessionInviteOptions } 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'; + +export type VoipEvents = Omit<CoreVoipEvents, 'ringing' | 'callestablished' | 'incomingcall'> & { + callestablished: ContactInfo; + incomingcall: ContactInfo; + outgoingcall: ContactInfo; + dialer: { open: boolean }; +}; + +type SessionError = { + status: number | undefined; + reason: string; + contact: ContactInfo; +}; + +class VoipClient extends Emitter<VoipEvents> { + protected registerer: Registerer | undefined; + + protected session: Session | undefined; + + public userAgent: UserAgent | undefined; + + public networkEmitter: Emitter<SignalingSocketEvents>; + + private mediaStreamRendered: IMediaStreamRenderer | undefined; + + private remoteStream: RemoteStream | undefined; + + private held = false; + + private muted = false; + + private online = true; + + private error: SessionError | null = null; + + private contactInfo: ContactInfo | null = null; + + constructor(private readonly config: VoIPUserConfiguration, mediaRenderer?: IMediaStreamRenderer) { + super(); + + this.mediaStreamRendered = mediaRenderer; + + this.networkEmitter = new Emitter<SignalingSocketEvents>(); + } + + public async init() { + const { authPassword, authUserName, sipRegistrarHostnameOrIP, iceServers, webSocketURI } = this.config; + + const transportOptions = { + server: webSocketURI, + connectionTimeout: 100, + keepAliveInterval: 20, + }; + + const sdpFactoryOptions = { + iceGatheringTimeout: 10, + peerConnectionConfiguration: { iceServers }, + }; + + this.userAgent = new UserAgent({ + authorizationPassword: authPassword, + authorizationUsername: authUserName, + uri: UserAgent.makeURI(`sip:${authUserName}@${sipRegistrarHostnameOrIP}`), + transportOptions, + sessionDescriptionHandlerFactoryOptions: sdpFactoryOptions, + logConfiguration: false, + logLevel: '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, mediaRenderer?: IMediaStreamRenderer): Promise<VoipClient> { + const voip = new VoipClient(config, mediaRenderer); + 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<SessionState, () => 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<void> => { + await this.registerer?.register({ + requestDelegate: { + onAccept: this.onRegistrationAccepted, + onReject: this.onRegistrationRejected, + }, + }); + }; + + public unregister = async (): Promise<void> => { + await this.registerer?.unregister({ + all: true, + requestDelegate: { + onAccept: this.onUnregistrationAccepted, + onReject: this.onUnregistrationRejected, + }, + }); + }; + + public call = async (calleeURI: string, mediaRenderer?: IMediaStreamRenderer): Promise<void> => { + 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.'); + } + + if (mediaRenderer) { + this.switchMediaRenderer(mediaRenderer); + } + + 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<void> => { + 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<void> => { + 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<void> => { + 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<OutgoingByeRequest | void> => { + 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: + case SessionState.Terminated: + break; + default: + throw new Error('Unknown state'); + } + + return Promise.resolve(); + }; + + public setMute = async (mute: boolean): Promise<void> => { + 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.'); + } + + try { + const options: SessionInviteOptions = { + requestDelegate: { + onAccept: (): void => { + this.muted = mute; + this.toggleMediaStreamTracks('sender', !this.muted); + this.toggleMediaStreamTracks('receiver', !this.muted); + this.emit('stateChanged'); + }, + onReject: (): void => { + this.toggleMediaStreamTracks('sender', !this.muted); + this.toggleMediaStreamTracks('receiver', !this.muted); + this.emit('muteerror'); + }, + }, + }; + + await this.session.invite(options); + + this.toggleMediaStreamTracks('sender', !this.muted); + this.toggleMediaStreamTracks('receiver', !this.muted); + } catch (error) { + if (error instanceof RequestPendingError) { + console.error(`[${this.session?.id}] A mute request is already in progress.`); + } + + this.emit('muteerror'); + throw error; + } + }; + + public setHold = async (hold: boolean): Promise<void> => { + 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<void> => { + // 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<void> { + const { connectionRetryCount } = this.config; + + if (!this.userAgent) { + return; + } + + if (connectionRetryCount !== -1 && reconnectionAttempt > connectionRetryCount) { + return; + } + + 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<boolean> { + if (!this.session) { + console.warn('changeAudioInputDevice() : No session.'); + 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 switchMediaRenderer(mediaRenderer: IMediaStreamRenderer): void { + if (!this.remoteStream) { + return; + } + + this.mediaStreamRendered = mediaRenderer; + this.remoteStream.init(mediaRenderer.remoteMediaElement); + this.remoteStream.play(); + } + + 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 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(), + }; + } + + 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); + const mediaElement = this.mediaStreamRendered?.remoteMediaElement; + + if (mediaElement) { + this.remoteStream.init(mediaElement); + 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<void> { + 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) { + this.error = error; + this.emit('stateChanged'); + } + + private onUserAgentConnected = (): void => { + this.networkEmitter.emit('connected'); + this.emit('stateChanged'); + }; + + private onUserAgentDisconnected = (error: any): void => { + this.networkEmitter.emit('disconnected'); + this.emit('stateChanged'); + + if (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 onIncomingCall = async (invitation: Invitation): Promise<void> => { + if (!this.isRegistered() || this.session) { + await invitation.reject(); + return; + } + + 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<void> => { + 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'); + }; +} + +export default VoipClient; diff --git a/packages/ui-voip/src/providers/VoipProvider.tsx b/packages/ui-voip/src/providers/VoipProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..18903c769ea31d730f54d33086de0e710a7c6146 --- /dev/null +++ b/packages/ui-voip/src/providers/VoipProvider.tsx @@ -0,0 +1,177 @@ +import { useEffectEvent, useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import type { Device } from '@rocket.chat/ui-contexts'; +import { useSetInputMediaDevice, useSetOutputMediaDevice, useSetting, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import type { ReactNode } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; + +import VoipPopup from '../components/VoipPopup'; +import VoipPopupPortal from '../components/VoipPopupPortal'; +import type { VoipContextValue } from '../contexts/VoipContext'; +import { VoipContext } from '../contexts/VoipContext'; +import { useVoipClient } from '../hooks/useVoipClient'; +import { useVoipSounds } from '../hooks/useVoipSounds'; + +const VoipProvider = ({ children }: { children: ReactNode }) => { + // Settings + const isVoipEnabled = useSetting<boolean>('VoIP_TeamCollab_Enabled') || false; + const [isLocalRegistered, setStorageRegistered] = useLocalStorage('voip-registered', true); + + // Hooks + const voipSounds = useVoipSounds(); + const { voipClient, error } = useVoipClient({ autoRegister: isLocalRegistered }); + const setOutputMediaDevice = useSetOutputMediaDevice(); + const setInputMediaDevice = useSetInputMediaDevice(); + const dispatchToastMessage = useToastMessageDispatch(); + const { t } = useTranslation(); + + // Refs + const remoteAudioMediaRef = useRef<HTMLAudioElement>(null); + + useEffect(() => { + if (!voipClient) { + return; + } + + const onCallEstablished = async (): Promise<void> => { + voipSounds.stopAll(); + + if (!voipClient) { + return; + } + + if (voipClient.isCallee()) { + return; + } + + if (!remoteAudioMediaRef.current) { + return; + } + + voipClient.switchMediaRenderer({ remoteMediaElement: remoteAudioMediaRef.current }); + }; + + const onNetworkDisconnected = (): void => { + if (voipClient.isOngoing()) { + voipClient.endCall(); + } + }; + + const onOutgoingCallRinging = (): void => { + voipSounds.play('outbound-call-ringing'); + }; + + const onIncomingCallRinging = (): void => { + voipSounds.play('telephone'); + }; + + const onCallTerminated = (): void => { + voipSounds.play('call-ended', false); + voipSounds.stopAll(); + }; + + const onRegistrationError = () => { + setStorageRegistered(false); + dispatchToastMessage({ type: 'error', message: t('Voice_calling_registration_failed') }); + }; + + 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.networkEmitter.on('disconnected', onNetworkDisconnected); + voipClient.networkEmitter.on('connectionerror', onNetworkDisconnected); + voipClient.networkEmitter.on('localnetworkoffline', onNetworkDisconnected); + + return (): void => { + 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.networkEmitter.off('disconnected', onNetworkDisconnected); + voipClient.networkEmitter.off('connectionerror', onNetworkDisconnected); + voipClient.networkEmitter.off('localnetworkoffline', onNetworkDisconnected); + }; + }, [dispatchToastMessage, setStorageRegistered, t, voipClient, voipSounds]); + + const changeAudioOutputDevice = useEffectEvent(async (selectedAudioDevice: Device): Promise<void> => { + if (!remoteAudioMediaRef.current) { + return; + } + + setOutputMediaDevice({ outputDevice: selectedAudioDevice, HTMLAudioElement: remoteAudioMediaRef.current }); + }); + + const changeAudioInputDevice = useEffectEvent(async (selectedAudioDevice: Device): Promise<void> => { + if (!voipClient) { + return; + } + + await voipClient.changeAudioInputDevice({ audio: { deviceId: { exact: selectedAudioDevice.id } } }); + setInputMediaDevice(selectedAudioDevice); + }); + + const contextValue = useMemo<VoipContextValue>(() => { + 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 ( + <VoipContext.Provider value={contextValue}> + {children} + {contextValue.isEnabled && + createPortal( + <audio ref={remoteAudioMediaRef}> + <track kind='captions' /> + </audio>, + document.body, + )} + + <VoipPopupPortal> + <VoipPopup position={{ bottom: 132, right: 24 }} /> + </VoipPopupPortal> + </VoipContext.Provider> + ); +}; + +export default VoipProvider; diff --git a/packages/ui-voip/src/tests/mocks/index.ts b/packages/ui-voip/src/tests/mocks/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..926b5408174e169e6a20cf142f5bbd80d7c4fb2d --- /dev/null +++ b/packages/ui-voip/src/tests/mocks/index.ts @@ -0,0 +1,75 @@ +import { OperationParams } from '@rocket.chat/rest-typings'; + +import { VoipErrorSession, VoipIncomingSession, VoipOngoingSession, VoipOutgoingSession, VoipSession } from '../../definitions'; + +export const createMockFreeSwitchExtensionDetails = ( + overwrite?: Partial<OperationParams<'GET', '/v1/voip-freeswitch.extension.getDetails'>>, +) => ({ + 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>): 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>): 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>): VoipErrorSession => ({ + type: 'ERROR', + contact: { name: 'test', id: '1000', host: '' }, + error: { status: -1, reason: '' }, + end: jest.fn(), + ...partial, +}); + +export const createMockVoipOutgoingSession = (partial?: Partial<VoipOutgoingSession>): VoipOutgoingSession => ({ + type: 'OUTGOING', + contact: { name: 'test', id: '1000', host: '' }, + end: jest.fn(), + ...partial, +}); + +export const createMockVoipIncomingSession = (partial?: Partial<VoipIncomingSession>): VoipIncomingSession => ({ + type: 'INCOMING', + contact: { name: 'test', id: '1000', host: '' }, + transferedBy: null, + end: jest.fn(), + accept: jest.fn(), + ...partial, +}); diff --git a/packages/ui-voip/src/tests/utils/replaceReactAriaIds.ts b/packages/ui-voip/src/tests/utils/replaceReactAriaIds.ts new file mode 100644 index 0000000000000000000000000000000000000000..3963f26e049ade376ef0a64ccf8f63688edeb9c0 --- /dev/null +++ b/packages/ui-voip/src/tests/utils/replaceReactAriaIds.ts @@ -0,0 +1,22 @@ +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<string, string> = {}; + + 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/parseStringToIceServers/index.ts b/packages/ui-voip/src/utils/parseStringToIceServers/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..290b8eb5f148dd33e93e7ad51db6102835b4a070 --- /dev/null +++ b/packages/ui-voip/src/utils/parseStringToIceServers/index.ts @@ -0,0 +1 @@ +export * from './parseStringToIceServers'; diff --git a/packages/ui-voip/src/utils/parseStringToIceServers/parseStringToIceServers.spec.ts b/packages/ui-voip/src/utils/parseStringToIceServers/parseStringToIceServers.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..b24564bfb165db5c1d50d7c120a479be989d281f --- /dev/null +++ b/packages/ui-voip/src/utils/parseStringToIceServers/parseStringToIceServers.spec.ts @@ -0,0 +1,50 @@ +import { parseStringToIceServers, parseStringToIceServer } from './parseStringToIceServers'; + +describe('parseStringToIceServers', () => { + describe('parseStringToIceServers', () => { + it('should parse return an empty array if string is empty', () => { + const result = parseStringToIceServers(''); + expect(result).toEqual([]); + }); + it('should parse string to servers', () => { + const servers = parseStringToIceServers('stun:stun.l.google.com:19302'); + expect(servers.length).toEqual(1); + expect(servers[0].urls).toEqual('stun:stun.l.google.com:19302'); + expect(servers[0].username).toEqual(undefined); + expect(servers[0].credential).toEqual(undefined); + }); + + it('should parse string to servers with multiple urls', () => { + const servers = parseStringToIceServers('stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302'); + expect(servers.length).toEqual(2); + expect(servers[0].urls).toEqual('stun:stun.l.google.com:19302'); + expect(servers[1].urls).toEqual('stun:stun1.l.google.com:19302'); + }); + + it('should parse string to servers with multiple urls, with password and username', () => { + const servers = parseStringToIceServers( + 'stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302,team%40rocket.chat:demo@turn:numb.viagenie.ca:3478', + ); + expect(servers.length).toEqual(3); + expect(servers[0].urls).toEqual('stun:stun.l.google.com:19302'); + expect(servers[1].urls).toEqual('stun:stun1.l.google.com:19302'); + expect(servers[2].urls).toEqual('turn:numb.viagenie.ca:3478'); + expect(servers[2].username).toEqual('team@rocket.chat'); + expect(servers[2].credential).toEqual('demo'); + }); + }); + + describe('parseStringToIceServer', () => { + it('should parse string to server', () => { + const server = parseStringToIceServer('stun:stun.l.google.com:19302'); + expect(server.urls).toEqual('stun:stun.l.google.com:19302'); + }); + + it('should parse string to server with username and password', () => { + const server = parseStringToIceServer('team%40rocket.chat:demo@turn:numb.viagenie.ca:3478'); + expect(server.urls).toEqual('turn:numb.viagenie.ca:3478'); + expect(server.username).toEqual('team@rocket.chat'); + expect(server.credential).toEqual('demo'); + }); + }); +}); diff --git a/packages/ui-voip/src/utils/parseStringToIceServers/parseStringToIceServers.ts b/packages/ui-voip/src/utils/parseStringToIceServers/parseStringToIceServers.ts new file mode 100644 index 0000000000000000000000000000000000000000..ffef45a6401c9a1538ea1b776af669acf3ef9a19 --- /dev/null +++ b/packages/ui-voip/src/utils/parseStringToIceServers/parseStringToIceServers.ts @@ -0,0 +1,21 @@ +import type { IceServer } from '../../definitions/IceServer'; + +export const parseStringToIceServer = (server: string): IceServer => { + const credentials = server.trim().split('@'); + const urls = credentials.pop() as string; + const [username, credential] = credentials.length === 1 ? credentials[0].split(':') : []; + + return { + urls, + ...(username && + credential && { + username: decodeURIComponent(username), + credential: decodeURIComponent(credential), + }), + }; +}; + +export const parseStringToIceServers = (string: string): IceServer[] => { + const lines = string.trim() ? string.split(',') : []; + return lines.map((line) => parseStringToIceServer(line)); +}; diff --git a/packages/ui-voip/src/utils/setPreciseInterval.ts b/packages/ui-voip/src/utils/setPreciseInterval.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f3fb12e3004f8bf6f5258c28b710d926a7bee96 --- /dev/null +++ b/packages/ui-voip/src/utils/setPreciseInterval.ts @@ -0,0 +1,25 @@ +export const setPreciseInterval = (fn: () => void, duration: number) => { + let timeoutId: Parameters<typeof clearTimeout>[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); + }; +}; diff --git a/packages/ui-voip/tsconfig.build.json b/packages/ui-voip/tsconfig.build.json new file mode 100644 index 0000000000000000000000000000000000000000..36dbe9c166ff5d63510a08c1d90ea05e15dd6976 --- /dev/null +++ b/packages/ui-voip/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["./src/**/*"], + "exclude": ["./src/**/*.stories.tsx", "./src/**/*.spec.ts", "./src/**/*.spec.tsx"] +} diff --git a/packages/ui-voip/tsconfig.json b/packages/ui-voip/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..aa282e17ea6cd0bddee5c98a89dead0d97f8d036 --- /dev/null +++ b/packages/ui-voip/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.client.json", + "compilerOptions": { + "rootDirs": ["./src", "./"], + "outDir": "./dist" + }, + "include": ["./src/**/*", "./jest.config.ts"], +} diff --git a/yarn.lock b/yarn.lock index 3ee9382dfd86c5db5e8dcc876cccf21f9cd639fa..c0ff720594ccabdb0813b18f9c06e8ec072a8eb2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9020,6 +9020,22 @@ __metadata: languageName: node linkType: hard +"@rocket.chat/freeswitch@workspace:^, @rocket.chat/freeswitch@workspace:packages/freeswitch": + version: 0.0.0-use.local + resolution: "@rocket.chat/freeswitch@workspace:packages/freeswitch" + dependencies: + "@rocket.chat/core-typings": "workspace:^" + "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/logger": "workspace:^" + "@rocket.chat/tools": "workspace:^" + "@types/jest": ~29.5.12 + esl: "github:pierre-lehnen-rc/esl" + eslint: ~8.45.0 + jest: ~29.7.0 + typescript: ~5.3.3 + languageName: unknown + linkType: soft + "@rocket.chat/fuselage-hooks@npm:^0.33.1": version: 0.33.1 resolution: "@rocket.chat/fuselage-hooks@npm:0.33.1" @@ -9542,6 +9558,7 @@ __metadata: "@rocket.chat/favicon": "workspace:^" "@rocket.chat/forked-matrix-appservice-bridge": ^4.0.2 "@rocket.chat/forked-matrix-bot-sdk": ^0.6.0-beta.3 + "@rocket.chat/freeswitch": "workspace:^" "@rocket.chat/fuselage": ^0.59.1 "@rocket.chat/fuselage-hooks": ^0.33.1 "@rocket.chat/fuselage-polyfills": ~0.31.25 @@ -9588,6 +9605,7 @@ __metadata: "@rocket.chat/ui-kit": "workspace:~" "@rocket.chat/ui-theming": "workspace:^" "@rocket.chat/ui-video-conf": "workspace:^" + "@rocket.chat/ui-voip": "workspace:^" "@rocket.chat/web-ui-registration": "workspace:^" "@settlin/spacebars-loader": ^1.0.9 "@slack/bolt": ^3.14.0 @@ -9729,6 +9747,7 @@ __metadata: emoji-toolkit: ^7.0.1 emojione: ^4.5.0 emojione-assets: ^4.5.0 + esl: "github:pierre-lehnen-rc/esl" eslint: ~8.45.0 eslint-config-prettier: ~8.8.0 eslint-plugin-anti-trojan-source: ~1.1.1 @@ -10681,6 +10700,63 @@ __metadata: languageName: unknown linkType: soft +"@rocket.chat/ui-voip@workspace:^, @rocket.chat/ui-voip@workspace:packages/ui-voip": + version: 0.0.0-use.local + resolution: "@rocket.chat/ui-voip@workspace:packages/ui-voip" + dependencies: + "@babel/core": ~7.22.20 + "@faker-js/faker": ~8.0.2 + "@rocket.chat/css-in-js": ~0.31.25 + "@rocket.chat/emitter": ~0.31.25 + "@rocket.chat/eslint-config": "workspace:^" + "@rocket.chat/fuselage": ^0.59.1 + "@rocket.chat/fuselage-hooks": ^0.33.1 + "@rocket.chat/icons": ~0.38.0 + "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/mock-providers": "workspace:~" + "@rocket.chat/styled": ~0.31.25 + "@rocket.chat/ui-avatar": "workspace:^" + "@rocket.chat/ui-client": "workspace:^" + "@rocket.chat/ui-contexts": "workspace:^" + "@storybook/addon-a11y": ^6.5.16 + "@storybook/addon-actions": ~6.5.16 + "@storybook/addon-docs": ~6.5.16 + "@storybook/addon-essentials": ~6.5.16 + "@storybook/builder-webpack4": ~6.5.16 + "@storybook/manager-webpack4": ~6.5.16 + "@storybook/react": ~6.5.16 + "@storybook/testing-library": ~0.0.13 + "@storybook/testing-react": ~1.3.0 + "@tanstack/react-query": ^4.16.1 + "@testing-library/react": ~16.0.1 + "@testing-library/user-event": ~14.5.2 + "@types/jest": ~29.5.12 + "@types/jest-axe": ~3.5.9 + eslint: ~8.45.0 + eslint-plugin-react: ~7.32.2 + eslint-plugin-react-hooks: ~4.6.0 + eslint-plugin-storybook: ~0.6.15 + jest: ~29.7.0 + jest-axe: ~9.0.0 + react-docgen-typescript-plugin: ~1.0.8 + react-i18next: ~15.0.1 + sip.js: ^0.20.1 + typescript: ~5.5.4 + peerDependencies: + "@rocket.chat/css-in-js": "*" + "@rocket.chat/fuselage": "*" + "@rocket.chat/fuselage-hooks": "*" + "@rocket.chat/icons": "*" + "@rocket.chat/styled": "*" + "@rocket.chat/ui-avatar": "*" + "@rocket.chat/ui-client": "*" + "@rocket.chat/ui-contexts": "*" + react: ^17.0.2 + react-aria: ~3.23.1 + react-dom: ^17.0.2 + languageName: unknown + linkType: soft + "@rocket.chat/uikit-playground@workspace:apps/uikit-playground": version: 0.0.0-use.local resolution: "@rocket.chat/uikit-playground@workspace:apps/uikit-playground" @@ -14014,7 +14090,7 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:~29.5.13": +"@types/jest@npm:~29.5.12, @types/jest@npm:~29.5.13": version: 29.5.13 resolution: "@types/jest@npm:29.5.13" dependencies: @@ -23169,6 +23245,13 @@ __metadata: languageName: node linkType: hard +"esl@github:pierre-lehnen-rc/esl": + version: 11.1.1 + resolution: "esl@https://github.com/pierre-lehnen-rc/esl.git#commit=22f2167a4acaa129d214592cfb0d46a419d08663" + checksum: 1e24a130650d916980ba3d332bc3fbac90a78ec9b13db7f7300318de3094e178d5c851c4b1c36d99c8a8ea800279ca07137d6a0c6335168cec01870cf61d820c + languageName: node + linkType: hard + "eslint-config-prettier@npm:~8.8.0": version: 8.8.0 resolution: "eslint-config-prettier@npm:8.8.0" @@ -23316,7 +23399,7 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-react-hooks@npm:^4.6.2, eslint-plugin-react-hooks@npm:~4.6.2": +"eslint-plugin-react-hooks@npm:^4.6.2, eslint-plugin-react-hooks@npm:~4.6.0, eslint-plugin-react-hooks@npm:~4.6.2": version: 4.6.2 resolution: "eslint-plugin-react-hooks@npm:4.6.2" peerDependencies: @@ -42200,6 +42283,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:~5.3.3": + version: 5.3.3 + resolution: "typescript@npm:5.3.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 2007ccb6e51bbbf6fde0a78099efe04dc1c3dfbdff04ca3b6a8bc717991862b39fd6126c0c3ebf2d2d98ac5e960bcaa873826bb2bb241f14277034148f41f6a2 + languageName: node + linkType: hard + "typescript@npm:~5.5.4": version: 5.5.4 resolution: "typescript@npm:5.5.4" @@ -42220,6 +42313,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@~5.3.3#~builtin<compat/typescript>": + version: 5.3.3 + resolution: "typescript@patch:typescript@npm%3A5.3.3#~builtin<compat/typescript>::version=5.3.3&hash=85af82" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: f61375590b3162599f0f0d5b8737877ac0a7bc52761dbb585d67e7b8753a3a4c42d9a554c4cc929f591ffcf3a2b0602f65ae3ce74714fd5652623a816862b610 + languageName: node + linkType: hard + "typescript@patch:typescript@~5.5.4#~builtin<compat/typescript>": version: 5.5.4 resolution: "typescript@patch:typescript@npm%3A5.5.4#~builtin<compat/typescript>::version=5.5.4&hash=85af82"