Skip to content

Commit

Permalink
feat: Voip for Team Collaboration (#33346)
Browse files Browse the repository at this point in the history
Co-authored-by: Aleksander Nicacio da Silva <6494543+aleksandernsilva@users.noreply.github.com>
  • Loading branch information
pierre-lehnen-rc and aleksandernsilva authored Sep 30, 2024
1 parent 662aca3 commit bcacbb1
Show file tree
Hide file tree
Showing 199 changed files with 8,996 additions and 163 deletions.
14 changes: 14 additions & 0 deletions .changeset/gorgeous-houses-sneeze.md
Original file line number Diff line number Diff line change
@@ -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
30 changes: 16 additions & 14 deletions apps/meteor/app/api/server/lib/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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' } }] : []),
Expand Down
19 changes: 17 additions & 2 deletions apps/meteor/app/api/server/v1/im.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: {
Expand Down
7 changes: 7 additions & 0 deletions apps/meteor/app/authorization/server/constant/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
Expand Down
3 changes: 3 additions & 0 deletions apps/meteor/app/lib/server/functions/getFullUserData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const fullFields = {
requirePasswordChangeReason: 1,
roles: 1,
importIds: 1,
freeSwitchExtension: 1,
} as const;

let publicCustomFields: Record<string, 0 | 1> = {};
Expand Down Expand Up @@ -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) {
Expand All @@ -96,6 +98,7 @@ export async function getFullUserDataByIdOrUsernameOrImportId(
const options = {
projection: {
...fields,
...(canViewExtension && { freeSwitchExtension: 1 }),
...(myself && { services: 1 }),
},
};
Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/app/models/client/models/Users.ts
Original file line number Diff line number Diff line change
@@ -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> {
Expand Down Expand Up @@ -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;
1 change: 1 addition & 0 deletions apps/meteor/app/utils/client/lib/SDKClient.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
14 changes: 14 additions & 0 deletions apps/meteor/client/NavBarV2/NavBar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand All @@ -31,13 +33,17 @@ 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);

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>
Expand All @@ -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')}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -35,6 +37,9 @@ export const useUserMenu = (user: IUser) => {
title: t('Status'),
items: statusItems,
},
{
items: voipItems,
},
{
title: t('Account'),
items: accountItems,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions apps/meteor/client/NavBarV2/NavBarVoipToolbar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as NavBarItemVoipDialer } from './NavBarItemVoipDialer';
18 changes: 12 additions & 6 deletions apps/meteor/client/components/UserInfo/UserInfoAction.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useStartCallRoomAction';
Loading

0 comments on commit bcacbb1

Please sign in to comment.