diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index a640318a9cd0..5f12d7d7b751 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/lib/server/functions/getFullUserData.ts b/apps/meteor/app/lib/server/functions/getFullUserData.ts index 0703b24d9210..f66f8ecb49c6 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 = {}; @@ -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/client/NavBarV2/NavBar.tsx b/apps/meteor/client/NavBarV2/NavBar.tsx index 908e729c956e..7e61d53e5eff 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 ( @@ -59,6 +65,14 @@ const NavBar = () => { )} + {showVoip && ( + <> + + + + + + )} diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx index 85a481f3e257..fce9c3d14fd4 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 000000000000..b3e4cbf22d52 --- /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: ( + + {isRegistered ? t('Disable_voice_calling') : t('Enable_voice_calling')} + + ), + }, + ]; + }, [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 000000000000..bdc62c41b1da --- /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, '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 ? ( + + ) : 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 000000000000..7f6d317af229 --- /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 97c64ecbede1..f58d0fb07482 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; -const UserInfoAction = ({ icon, label, ...props }: UserInfoActionProps): ReactElement => ( - -); +const UserInfoAction = ({ icon, label, title, ...props }: UserInfoActionProps): ReactElement => { + if (!label && icon && title) { + return ; + } + + return ( + + ); +}; 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 000000000000..d01e5a6a5dff --- /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 000000000000..ee3117d664d1 --- /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 }) => ( + } + 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 8d1fa251c051..13b92e7f44a5 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: ( + + {t('Video_call')} + + ), + }, + ]; 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 000000000000..ca62f372f4ca --- /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: ( + + {t('Voice_call')} + + ), + }, + ]; + + 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 4fac5212b803..fdbe793d60e3 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/sidebar/header/hooks/useUserMenu.tsx b/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx index c0c6f94a4ed8..e9ad8cc73836 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 000000000000..d7cbf2428c32 --- /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: ( + + {isRegistered ? t('Disable_voice_calling') : t('Enable_voice_calling')} + + ), + }, + ]; + }, [isEnabled, isRegistered, isReady, tooltip, t, toggleVoip]); +}; + +export default useVoipItems; diff --git a/apps/meteor/client/views/admin/users/voip/AssignExtensionModal.tsx b/apps/meteor/client/views/admin/users/voip/AssignExtensionModal.tsx index 1d16ade1bf76..8e13731028d1 100644 --- a/apps/meteor/client/views/admin/users/voip/AssignExtensionModal.tsx +++ b/apps/meteor/client/views/admin/users/voip/AssignExtensionModal.tsx @@ -66,7 +66,7 @@ const AssignExtensionModal = ({ defaultExtension, defaultUsername, onClose }: As queryClient.invalidateQueries(['users.list']); if (loggedUser?.username === username) { - queryClient.invalidateQueries(['voice-call-client']); + queryClient.invalidateQueries(['voip-client']); } onClose(); diff --git a/apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.tsx b/apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.tsx index 1597da664348..bd93ce9ebef1 100644 --- a/apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.tsx +++ b/apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.tsx @@ -32,7 +32,7 @@ const RemoveExtensionModal = ({ name, extension, username, onClose }: RemoveExte queryClient.invalidateQueries(['users.list']); if (loggedUser?.username === username) { - queryClient.invalidateQueries(['voice-call-client']); + queryClient.invalidateQueries(['voip-client']); } onClose(); diff --git a/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx b/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx index a26b5f31e77d..feaf12fd6b04 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 && , 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 => ( - + const mapAction = ([key, { content, title, icon, onClick }]: any): ReactElement => ( + ); 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 214dff25dfac..d14c5d8a692c 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; +type RoomMemberUser = Pick; 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 453f55fc71a3..a9ba5dd9dc0c 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 & { 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 8b4d5ad8cb52..562d36536dbe 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; +} & Pick; -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 {showButton ? ( - + ) : ( )} diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersRow.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersRow.tsx index 90c827194c24..acac41abf196 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; + user: Pick; data: { onClickView: (e: MouseEvent) => 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 b60bcb749f5b..bf600a757f26 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; + user: Pick; 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 => ( - + const mapAction = ([key, { content, title, icon, onClick }]: any): ReactElement => ( + ); 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 b5049bebdc1f..8f35b56a1c33 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: , 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 27fd6de58520..8b3f0e5307b5 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): UserInfoAction | undefined => { +export const useVideoCallAction = (user: Pick): UserInfoAction | undefined => { const t = useTranslation(); const usernameSubscription = useUserSubscriptionByName(user.username ?? ''); const room = useUserRoom(usernameSubscription?.rid || ''); @@ -24,7 +24,7 @@ export const useCallAction = (user: Pick): UserInfoAc const enabledForDMs = useSetting('VideoConf_Enable_DMs'); const permittedToCallManagement = usePermission('call-management', room?._id); - const videoCallOption = useMemo(() => { + const videoCallOption = useMemo(() => { const action = async (): Promise => { if (isCalling || isRinging || !room) { return; @@ -44,10 +44,10 @@ export const useCallAction = (user: Pick): 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 000000000000..da02cf26e560 --- /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): UserInfoAction | undefined => { + const { t } = useTranslation(); + const { closeUserCard } = useUserCard(); + const ownUserId = useUserId(); + + const { isEnabled, isRegistered, isInCall } = useVoipState(); + const { makeCall } = useVoipAPI(); + const [isMicPermissionDenied] = useMediaPermissions('microphone'); + + const isRemoteRegistered = !!user?.freeSwitchExtension; + const isSameUser = ownUserId === user._id; + + const disabled = isSameUser || isMicPermissionDenied || !isRemoteRegistered || !isRegistered || isInCall; + + const voipCallOption = useMemo(() => { + const handleClick = () => { + makeCall(user?.freeSwitchExtension as string); + closeUserCard(); + }; + + return isEnabled && !isSameUser + ? { + type: 'communication', + title: t('Voice_call'), + icon: 'phone', + disabled, + onClick: handleClick, + } + : undefined; + }, [closeUserCard, disabled, isEnabled, isSameUser, makeCall, t, user?.freeSwitchExtension]); + + return voipCallOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts index c04df6b7521a..90dbfbbaba4c 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['name']; +type UserInfoActionWithOnlyIcon = { + type?: UserInfoActionType; + content?: string; + icon: ComponentProps['name']; + title: string; + variant?: 'danger'; onClick: () => void; +}; + +type UserInfoActionWithContent = { type?: UserInfoActionType; + content: string; + icon?: ComponentProps['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, - rid: IRoom['_id'], - reload?: () => void, +type UserInfoActionsParams = { + user: Pick; + 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/tests/e2e/channel-management.spec.ts b/apps/meteor/tests/e2e/channel-management.spec.ts index cd3ab365240f..15a0ef13eff2 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 9d5e2081ca93..5962dc29451c 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 3c6ba1730e3d..0b26c16cffc6 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 f49786f77598..e5ec8a6f8c1d 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/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 7bd6b84f9ac7..1ea0f8d0d86c 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1722,6 +1722,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", @@ -1961,6 +1962,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", @@ -5555,6 +5557,7 @@ "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", @@ -5795,6 +5798,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", @@ -5956,7 +5960,10 @@ "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", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index 107aa870ae88..cf06ddc01a89 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -1464,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", @@ -1648,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.", @@ -4675,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", @@ -4764,6 +4767,9 @@ "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.", 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 index 206f4187d6d1..8c8df4a661ec 100644 --- a/packages/ui-voip/src/components/VoipPopup/__snapshots__/VoipPopup.spec.tsx.snap +++ b/packages/ui-voip/src/components/VoipPopup/__snapshots__/VoipPopup.spec.tsx.snap @@ -8,7 +8,7 @@ exports[`renders ErrorCall without crashing 1`] = ` hidden="" />
@@ -17,7 +17,7 @@ exports[`renders ErrorCall without crashing 1`] = ` >

New_Call

@@ -409,7 +409,7 @@ exports[`renders IncomingCall without crashing 1`] = ` hidden="" />
@@ -418,7 +418,7 @@ exports[`renders IncomingCall without crashing 1`] = ` >

New_Call

@@ -810,7 +810,7 @@ exports[`renders OngoingCall without crashing 1`] = ` hidden="" />
@@ -819,7 +819,7 @@ exports[`renders OngoingCall without crashing 1`] = ` >

New_Call

@@ -1211,7 +1211,7 @@ exports[`renders OutgoingCall without crashing 1`] = ` hidden="" />
@@ -1220,7 +1220,7 @@ exports[`renders OutgoingCall without crashing 1`] = ` >

New_Call

diff --git a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupContainer.tsx b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupContainer.tsx index 20edbb4b7df1..048501d94062 100644 --- a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupContainer.tsx +++ b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupContainer.tsx @@ -40,7 +40,7 @@ const Container = styled( const VoipPopupContainer = ({ children, secondary = false, position = { top: 0, left: 0 }, ...props }: ContainerProps) => ( - + {children} diff --git a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupHeader.tsx b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupHeader.tsx index a32a4d473a89..51ae01ef490e 100644 --- a/packages/ui-voip/src/components/VoipPopup/components/VoipPopupHeader.tsx +++ b/packages/ui-voip/src/components/VoipPopup/components/VoipPopupHeader.tsx @@ -16,7 +16,7 @@ const VoipPopupHeader = ({ children, hideSettings, onClose }: VoipPopupHeaderPro return ( {children && ( - + {children} )} diff --git a/packages/ui-voip/src/components/VoipPopupPortal/VoipPopupPortal.tsx b/packages/ui-voip/src/components/VoipPopupPortal/VoipPopupPortal.tsx index 40c5061df0fb..d0f3c015e51b 100644 --- a/packages/ui-voip/src/components/VoipPopupPortal/VoipPopupPortal.tsx +++ b/packages/ui-voip/src/components/VoipPopupPortal/VoipPopupPortal.tsx @@ -1,20 +1,15 @@ +import { AnchorPortal } from '@rocket.chat/ui-client'; import type { ReactElement, ReactNode } from 'react'; -import { memo, useEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; +import { memo } from 'react'; -import { createAnchor } from '../../utils/createAnchor'; -import { deleteAnchor } from '../../utils/deleteAnchor'; +const voipAnchorId = 'voip-root'; type VoipPopupPortalProps = { children?: ReactNode; }; const VoipPopupPortal = ({ children }: VoipPopupPortalProps): ReactElement => { - const [voiceCallRoot] = useState(() => createAnchor('voice-call-root')); - - useEffect(() => (): void => deleteAnchor(voiceCallRoot), [voiceCallRoot]); - - return <>{createPortal(children, voiceCallRoot)}; + return {children}; }; export default memo(VoipPopupPortal); diff --git a/packages/ui-voip/src/hooks/useVoipClient.tsx b/packages/ui-voip/src/hooks/useVoipClient.tsx index c4173730a9b9..14ff278ca022 100644 --- a/packages/ui-voip/src/hooks/useVoipClient.tsx +++ b/packages/ui-voip/src/hooks/useVoipClient.tsx @@ -24,7 +24,7 @@ export const useVoipClient = ({ autoRegister = true }: VoipClientParams): VoipCl const iceServers = useWebRtcServers(); const { data: voipClient, error } = useQuery( - ['voice-call-client', isVoipEnabled, userId, iceServers], + ['voip-client', isVoipEnabled, userId, iceServers], async () => { if (voipClientRef.current) { voipClientRef.current.clear(); diff --git a/packages/ui-voip/src/hooks/useVoipExtensionDetails.tsx b/packages/ui-voip/src/hooks/useVoipExtensionDetails.tsx index 9cfc8d28c1bc..d106ae2842aa 100644 --- a/packages/ui-voip/src/hooks/useVoipExtensionDetails.tsx +++ b/packages/ui-voip/src/hooks/useVoipExtensionDetails.tsx @@ -5,7 +5,7 @@ export const useVoipExtensionDetails = ({ extension, enabled = true }: { extensi const isEnabled = !!extension && enabled; const getContactDetails = useEndpoint('GET', '/v1/voip-freeswitch.extension.getDetails'); const { data, ...result } = useQuery( - ['voice-call', 'voice-call-extension-details', extension, getContactDetails], + ['voip', 'voip-extension-details', extension, getContactDetails], () => getContactDetails({ extension: extension as string }), { enabled: isEnabled, diff --git a/packages/ui-voip/src/providers/VoipProvider.tsx b/packages/ui-voip/src/providers/VoipProvider.tsx index 136c3f870e1f..18903c769ea3 100644 --- a/packages/ui-voip/src/providers/VoipProvider.tsx +++ b/packages/ui-voip/src/providers/VoipProvider.tsx @@ -16,7 +16,7 @@ import { useVoipSounds } from '../hooks/useVoipSounds'; const VoipProvider = ({ children }: { children: ReactNode }) => { // Settings const isVoipEnabled = useSetting('VoIP_TeamCollab_Enabled') || false; - const [isLocalRegistered, setStorageRegistered] = useLocalStorage('voice-call-registered', true); + const [isLocalRegistered, setStorageRegistered] = useLocalStorage('voip-registered', true); // Hooks const voipSounds = useVoipSounds(); diff --git a/packages/ui-voip/src/utils/createAnchor.ts b/packages/ui-voip/src/utils/createAnchor.ts deleted file mode 100644 index 836b30582f91..000000000000 --- a/packages/ui-voip/src/utils/createAnchor.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { registerAnchor } from './deleteAnchor'; - -type T = keyof HTMLElementTagNameMap; - -export const createAnchor: { - (id: string, tag?: T): T extends undefined ? HTMLElementTagNameMap['div'] : HTMLElementTagNameMap[T]; -} = (id: string, tag = 'div') => { - const anchor = document.getElementById(id); - if (anchor && anchor.tagName.toLowerCase() === tag) { - return anchor as any; - } - const a = document.createElement(tag); - a.id = id; - document.body.appendChild(a); - - registerAnchor(a, () => document.body.removeChild(a)); - return a; -}; diff --git a/packages/ui-voip/src/utils/deleteAnchor.ts b/packages/ui-voip/src/utils/deleteAnchor.ts deleted file mode 100644 index 33e34046ea3b..000000000000 --- a/packages/ui-voip/src/utils/deleteAnchor.ts +++ /dev/null @@ -1,11 +0,0 @@ -const anchor = new WeakMap void>(); - -export const deleteAnchor = (element: HTMLElement): void => { - const fn = anchor.get(element); - if (fn) { - fn(); - } -}; -export const registerAnchor = (element: HTMLElement, fn: () => void): void => { - anchor.set(element, fn); -};