Skip to content

Commit

Permalink
feat: VoIP freeswitch UI start call actions (#33008)
Browse files Browse the repository at this point in the history
* feat: added options to useUserInfoQuery

* feat: added freeSwitchExtension to full user data

* feat: added freeSwitchExtension to /v1/im.members

* feat: implemented enable/disable voice call menu item

* feat: implemented start call menu into the room toolbox

* feat: start call actions for user/members contextual bars and user card

* feat: added enable/disable voice call button to NavBarV2

* feat: added dialer button to NavBarV2

* feat: made dialer button primary only if Omnichannel's voip is enabled

* feat: adjusted call toolbox action to be more compatible

* test: adjusted channel and video conf e2e tests to account for the new call menu

* fix: start call menu action not acepting className

* feat: improved error feedback

* Use 'view-user-voip-extension' permission to decide if extension should be loaded

* fix: passing incorrect ref

* fix: adjusted dialer error feedback

* refactor: renamed voice call with voip

* refactor: removed iconOnly prop and kept same behavior with logic

* refactor: reduced the use of useMemo in the useVideoConfMenuOptions hook

* Use `AnchorPortal`

---------

Co-authored-by: Pierre <pierre.lehnen@rocket.chat>
Co-authored-by: Tasso <tasso.evangelista@rocket.chat>
  • Loading branch information
3 people authored Sep 26, 2024
1 parent c81fc2f commit 175b2b8
Show file tree
Hide file tree
Showing 42 changed files with 593 additions and 163 deletions.
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
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
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';
Original file line number Diff line number Diff line change
@@ -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]);
};
Loading

0 comments on commit 175b2b8

Please sign in to comment.