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"