[MM-71076] Calls: Add phone call options menu to the call button in DMs and profile popover (#38941)

This commit is contained in:
M-ZubairAhmed
2026-10-07 19:16:27 +05:30
committed by GitHub
parent dc88bb3d50
commit e8d7f37e92
20 changed files with 751 additions and 69 deletions
@@ -65,6 +65,36 @@ On iOS, audio will automatically come through a connected device. You can overri
</TabItem>
</Tabs>
## Call a phone number
If your system admin has enabled outbound phone dialing for Calls, you can call a phone number from Mattermost using a web browser.
### Call a person's phone
You can call a person's phone when they have a phone number in their profile. Phone numbers come from [user attributes](/administration-guide/manage/admin/user-attributes#add-attributes) of the **Phone** type. Attributes set to **Always hide** can't be called.
1. Open a direct message with the person and select **Start call** in the channel header. Alternatively, select the person's name or profile picture to open their profile, then select the **Phone** icon.
2. Choose one of the following options:
- **Start an Audio Call** starts a Mattermost call in the direct message.
- **Call** followed by the attribute name, such as **Call Mobile**, dials that phone number. Each of the person's phone numbers is listed as its own option, with the number shown below it.
If the person has no phone number in their profile, or a call is already in progress in the direct message, the button starts a Mattermost call as usual. You can't call the phone numbers of bots or deactivated users.
### Call any phone number
- Use the `/call dial` slash command followed by the number, such as `/call dial +1 555-123-4567`.
- Select a [phone number link](/end-user-guide/collaborate/format-messages) in a message. When outbound phone dialing is turned off, or you're using a mobile browser, the link opens your device's own dialer instead.
### During and after a phone call
Phone calls take place in your direct message with the **Calls** bot. A call card in that direct message shows the call's progress. To end the call, select **Hang up** on the card, or leave the call from the call widget. When the call ends, the card shows how it ended, such as **Call ended**, **No answer**, or **Call declined**. Select **Call again** to dial the same number again.
<Note>
- You can't place a phone call while you're in another call. Leave the current call first.
- Your system admin can limit phone calls to members of specific teams, or to an approved list of numbers. If a call isn't permitted, Mattermost tells you when you try to dial.
</Note>
## Host controls
@@ -0,0 +1,5 @@
// The profile popover's overlay sits above menus, so its call menu has to sit above the overlay
// or clicks on the menu land on the overlay and close the popover instead.
.MuiPopover-root:has(#profilePopoverCallOptionsMenu) {
z-index: calc(var(--z-index-popover) + 1);
}
@@ -0,0 +1,237 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import ProfilePopoverCallButtonWrapper from 'components/profile_popover/profile_popover_call_button_wrapper';
import CallButton from 'plugins/call_button/call_button';
import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {CallButtonAction} from 'types/store/plugins';
import {getDialablePhones} from './use_phone_call_options';
const currentUserId = 'current_user';
const userId = 'user1';
const dmChannel = TestHelper.getChannelMock({
id: 'dm_channel_id',
name: `${currentUserId}__${userId}`,
display_name: `${currentUserId}__${userId}`,
type: 'D',
delete_at: 0,
});
const openChannel = TestHelper.getChannelMock({id: 'open_channel_id', name: 'town-square', type: 'O', delete_at: 0});
const channelMember = TestHelper.getChannelMembershipMock({channel_id: dmChannel.id});
const phoneField = (id: string, name: string, attrs: Partial<UserPropertyField['attrs']> = {}) => ({
id,
name,
type: 'text',
attrs: {sort_order: 0, visibility: 'when_set', value_type: 'phone', ...attrs},
} as UserPropertyField);
const dsnField = phoneField('dsn', 'DSN');
const makeCallButton = (withPhoneAction = true) => ({
id: 'call_button',
pluginId: 'com.mattermost.calls',
button: <button>{'Plugin call button'}</button>,
dropdownButton: <span>{'Plugin dropdown button'}</span>,
action: jest.fn(),
phoneAction: withPhoneAction ? jest.fn() : undefined,
} as unknown as CallButtonAction);
const makeState = (opts: {callButtons: CallButtonAction[]; dialing?: boolean; sessions?: object}) => ({
'plugins-com.mattermost.calls': {
callsConfig: {DefaultEnabled: true, EnableSIPOutbound: opts.dialing ?? true},
sessions: opts.sessions ?? {},
channels: {},
},
plugins: {
plugins: {'com.mattermost.calls': {id: 'com.mattermost.calls', version: '1.0.0'}},
components: {CallButton: opts.callButtons},
},
entities: {
general: {customProfileAttributes: {[dsnField.id]: dsnField}},
channels: {
currentChannelId: dmChannel.id,
channels: {[dmChannel.id]: dmChannel},
myMembers: {[dmChannel.id]: channelMember},
},
users: {
currentUserId,
profiles: {
[currentUserId]: TestHelper.getUserMock({id: currentUserId, roles: 'system_user'}),
[userId]: TestHelper.getUserMock({id: userId, username: 'leonard', custom_profile_attributes: {[dsnField.id]: '312-555-0174'}}),
},
},
},
views: {rhs: {isSidebarOpen: false}},
});
describe('getDialablePhones', () => {
test('returns the phone attributes that have a value', () => {
const fields = [
dsnField,
phoneField('mobile', 'mobile_phone', {display_name: 'Mobile'}),
phoneField('empty', 'Empty'),
{...phoneField('email', 'Email'), attrs: {...dsnField.attrs, value_type: 'email'}} as UserPropertyField,
];
expect(getDialablePhones(fields, {dsn: ' 312-555-0174 ', mobile: '+1 555 0100', empty: '', email: 'a@b.c'})).toEqual([
{fieldId: 'dsn', label: 'DSN', number: '312-555-0174'},
{fieldId: 'mobile', label: 'Mobile', number: '+1 555 0100'},
]);
});
test('skips attributes the profile popover hides', () => {
const fields = [
phoneField('hidden', 'Hidden', {visibility: 'hidden'}),
phoneField('source_only', 'Source only', {access_mode: 'source_only'}),
];
expect(getDialablePhones(fields, {hidden: '1', source_only: '2'})).toEqual([]);
});
test('is empty until the values load', () => {
expect(getDialablePhones([dsnField], undefined)).toEqual([]);
});
});
describe('profile popover call button', () => {
const renderPopoverButton = (state: ReturnType<typeof makeState>, hide = jest.fn()) => renderWithContext(
<ProfilePopoverCallButtonWrapper
userId={userId}
currentUserId={currentUserId}
fullname='Leonard Riley'
username='leonard'
hide={hide}
/>,
state,
{pluginReducers: ['plugins-com.mattermost.calls']},
);
test('opens the menu instead of starting a call', async () => {
const callButton = makeCallButton();
renderPopoverButton(makeState({callButtons: [callButton]}));
await userEvent.click(screen.getByRole('button', {name: 'Call options'}));
expect(screen.getByText('Start an Audio Call')).toBeInTheDocument();
expect(screen.getByText('Call DSN')).toBeInTheDocument();
expect(screen.getByText('312-555-0174 • Phone call')).toBeInTheDocument();
expect(callButton.action).not.toHaveBeenCalled();
});
test('dials a number through the plugin and closes the popover', async () => {
const callButton = makeCallButton();
const hide = jest.fn();
renderPopoverButton(makeState({callButtons: [callButton]}), hide);
await userEvent.click(screen.getByRole('button', {name: 'Call options'}));
await userEvent.click(screen.getByText('Call DSN'));
await waitFor(() => expect(callButton.phoneAction).toHaveBeenCalledWith({number: '312-555-0174', userId, label: 'DSN', fieldId: 'dsn'}));
expect(hide).toHaveBeenCalled();
expect(callButton.action).not.toHaveBeenCalled();
});
test('starts a call in the DM from the menu', async () => {
const callButton = makeCallButton();
renderPopoverButton(makeState({callButtons: [callButton]}));
await userEvent.click(screen.getByRole('button', {name: 'Call options'}));
await userEvent.click(screen.getByText('Start an Audio Call'));
await waitFor(() => expect(callButton.action).toHaveBeenCalledWith(dmChannel, channelMember));
});
test.each([
['the plugin has no phone action', {callButtons: [makeCallButton(false)]}],
['outbound dialing is off', {callButtons: [makeCallButton()], dialing: false}],
])('keeps the plain button when %s', async (_label, opts) => {
renderPopoverButton(makeState(opts));
expect(screen.queryByRole('button', {name: 'Call options'})).not.toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Start Call'));
expect(opts.callButtons[0].action).toHaveBeenCalledWith(dmChannel, channelMember);
});
test('keeps the disabled button while a call is ongoing', () => {
renderPopoverButton(makeState({
callButtons: [makeCallButton()],
sessions: {[dmChannel.id]: {session1: {user_id: userId}}},
}));
expect(screen.queryByRole('button', {name: 'Call options'})).not.toBeInTheDocument();
expect(screen.getByLabelText('Call with Leonard Riley is ongoing')).toBeDisabled();
});
});
describe('channel header call button', () => {
const renderHeaderButton = (callButtons: CallButtonAction[], channel = dmChannel, state = makeState({callButtons})) => renderWithContext(
<CallButton
pluginCallComponents={callButtons}
currentChannel={channel}
channelMember={channelMember}
sidebarOpen={false}
/>,
state,
{pluginReducers: ['plugins-com.mattermost.calls']},
);
test('in a DM, opens the menu instead of starting a call', async () => {
const callButton = makeCallButton();
renderHeaderButton([callButton]);
expect(screen.getByText('Start call')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', {name: 'Call options'}));
expect(screen.getByText('Call DSN')).toBeInTheDocument();
expect(callButton.action).not.toHaveBeenCalled();
});
test('dials a number or starts a call from the menu', async () => {
const callButton = makeCallButton();
renderHeaderButton([callButton]);
await userEvent.click(screen.getByRole('button', {name: 'Call options'}));
await userEvent.click(screen.getByText('Call DSN'));
await waitFor(() => expect(callButton.phoneAction).toHaveBeenCalledWith({number: '312-555-0174', userId, label: 'DSN', fieldId: 'dsn'}));
await userEvent.click(screen.getByRole('button', {name: 'Call options'}));
await userEvent.click(screen.getByText('Start an Audio Call'));
await waitFor(() => expect(callButton.action).toHaveBeenCalledWith(dmChannel, channelMember));
});
test('outside a DM, keeps the plugin button', async () => {
const callButton = makeCallButton();
renderHeaderButton([callButton], openChannel);
expect(screen.queryByRole('button', {name: 'Call options'})).not.toBeInTheDocument();
await userEvent.click(screen.getByText('Plugin call button'));
expect(callButton.action).toHaveBeenCalled();
});
test('keeps the plugin button while a call is ongoing', () => {
const callButton = makeCallButton();
renderHeaderButton([callButton], dmChannel, makeState({
callButtons: [callButton],
sessions: {[dmChannel.id]: {session1: {user_id: userId}}},
}));
expect(screen.queryByRole('button', {name: 'Call options'})).not.toBeInTheDocument();
expect(screen.getByText('Plugin call button')).toBeInTheDocument();
});
test('with two call plugins, keeps the multi-plugin dropdown', () => {
renderHeaderButton([makeCallButton(), {...makeCallButton(), id: 'other_call_button'} as CallButtonAction]);
expect(screen.queryByRole('button', {name: 'Call options'})).not.toBeInTheDocument();
expect(screen.getByText('Call')).toBeInTheDocument();
});
});
@@ -0,0 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PopoverOrigin} from '@mui/material/Popover';
import React from 'react';
import type {ComponentType} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import HeadphonesIcon from '@mattermost/compass-icons/components/headphones';
import PhoneInTalkIcon from '@mattermost/compass-icons/components/phone-in-talk';
import * as Menu from 'components/menu';
import type {MenuButtonComponentProps} from 'components/menu';
import type {DialablePhone} from './use_phone_call_options';
import './call_options_menu.scss';
type Props = {
menuId: string;
buttonId: string;
button: ComponentType<MenuButtonComponentProps>;
disabled?: boolean;
phones: DialablePhone[];
onStartCall: () => void;
onPhoneCall: (phone: DialablePhone) => void;
anchorOrigin?: PopoverOrigin;
transformOrigin?: PopoverOrigin;
};
/**
* A call button that opens a menu instead of starting a call: a call through the calls plugin,
* or a phone call to each of the other person's numbers.
*/
const CallOptionsMenu = ({
menuId,
buttonId,
button,
disabled,
phones,
onStartCall,
onPhoneCall,
anchorOrigin,
transformOrigin,
}: Props) => {
const {formatMessage} = useIntl();
const ariaLabel = formatMessage({id: 'call_options.aria_label', defaultMessage: 'Call options'});
return (
<Menu.Container
menuButton={{
id: buttonId,
as: button,
'aria-label': ariaLabel,
disabled,
}}
menu={{
id: menuId,
'aria-label': ariaLabel,
className: 'callOptionsMenu',
}}
anchorOrigin={anchorOrigin}
transformOrigin={transformOrigin}
>
<Menu.Item
id={`${menuId}-audio`}
leadingElement={<HeadphonesIcon size={18}/>}
labels={
<>
<span>
<FormattedMessage
id='call_options.audio'
defaultMessage='Start an Audio Call'
/>
</span>
<span>
<FormattedMessage
id='call_options.audio_sub'
defaultMessage='Mattermost Call'
/>
</span>
</>
}
onClick={onStartCall}
/>
{phones.map((phone) => (
<Menu.Item
key={phone.fieldId}
id={`${menuId}-phone-${phone.fieldId}`}
leadingElement={<PhoneInTalkIcon size={18}/>}
labels={
<>
<span>
<FormattedMessage
id='call_options.phone'
defaultMessage='Call {label}'
values={{label: phone.label}}
/>
</span>
<span>
<FormattedMessage
id='call_options.phone_sub'
defaultMessage='{number} • Phone call'
values={{number: phone.number}}
/>
</span>
</>
}
onClick={() => onPhoneCall(phone)}
/>
))}
</Menu.Container>
);
};
export default CallOptionsMenu;
@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default} from './call_options_menu';
export {default as useCanStartCall, canStartCall} from './use_can_start_call';
export {default as usePhoneCallOptions, getDialablePhones} from './use_phone_call_options';
export type {DialablePhone} from './use_phone_call_options';
export {default as useStartDMCall} from './use_start_dm_call';
@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useSelector} from 'react-redux';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {isSystemAdmin} from 'mattermost-redux/utils/user_utils';
import {
callsChannelExplicitlyDisabled,
callsChannelExplicitlyEnabled,
getCallsConfig,
isCallsEnabled,
} from 'selectors/calls';
import type {GlobalState} from 'types/store';
export function canStartCall(state: GlobalState, channelId: string, currentUserId: string): boolean {
// 1. No one gets a call button if the plugin is disabled.
if (!isCallsEnabled(state)) {
return false;
}
// 2. No one gets it if calls have been explicitly disabled in the channel.
if (callsChannelExplicitlyDisabled(state, channelId)) {
return false;
}
// 3. Admins get it unless calls have been explicitly disabled in the channel, test mode
// (DefaultEnabled = false) included.
if (isSystemAdmin(getUser(state, currentUserId)?.roles)) {
return true;
}
// 4. Users only get it if test mode is off and calls in the channel are not disabled.
if (getCallsConfig(state).DefaultEnabled) {
return true;
}
// 5. Everyone gets it if calls have been explicitly enabled in the channel, regardless of test mode.
return callsChannelExplicitlyEnabled(state, channelId);
}
export default function useCanStartCall(channelId: string, currentUserId: string, enabled = true): boolean {
return useSelector((state: GlobalState) => enabled && canStartCall(state, channelId, currentUserId));
}
@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useMemo} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import type {UserProfile} from '@mattermost/types/users';
import {getCustomProfileAttributeValues} from 'mattermost-redux/actions/users';
import {getCustomProfileAttributes} from 'mattermost-redux/selectors/entities/general';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {isCallsPhoneDialingEnabled} from 'selectors/calls';
import {getUserPropertyFieldLabel} from 'utils/properties';
import type {GlobalState} from 'types/store';
export type DialablePhone = {
fieldId: string;
label: string;
number: string;
};
const NO_FIELDS: UserPropertyField[] = [];
// getDialablePhones returns the phone numbers among a user's profile attributes, in attribute order,
// leaving out the ones the profile popover doesn't show either.
export function getDialablePhones(fields: UserPropertyField[], values: UserProfile['custom_profile_attributes']): DialablePhone[] {
if (!values) {
return [];
}
const phones: DialablePhone[] = [];
for (const field of fields) {
if (field.type !== 'text' || field.attrs?.value_type !== 'phone') {
continue;
}
if (field.attrs?.visibility === 'hidden' || field.attrs?.access_mode === 'source_only') {
continue;
}
const value = values[field.id];
const number = typeof value === 'string' ? value.trim() : '';
if (number) {
phones.push({fieldId: field.id, label: getUserPropertyFieldLabel(field), number});
}
}
return phones;
}
/**
* Returns the numbers a call button can offer to phone this user on: empty unless the calls plugin
* registered a phone action, outbound dialing is on, and the user can be called.
*/
export default function usePhoneCallOptions(userId: string | undefined, hasPhoneAction: boolean): DialablePhone[] {
const dispatch = useDispatch();
const dialingEnabled = useSelector((state: GlobalState) => hasPhoneAction && isCallsPhoneDialingEnabled(state));
const user = useSelector((state: GlobalState) => (dialingEnabled && userId ? getUser(state, userId) : undefined));
const active = Boolean(user) && !user?.is_bot && !user?.delete_at;
const fields = useSelector((state: GlobalState) => (active ? getCustomProfileAttributes(state) : NO_FIELDS));
const values = user?.custom_profile_attributes;
useEffect(() => {
if (active && userId && !values) {
dispatch(getCustomProfileAttributeValues(userId));
}
}, [dispatch, active, userId, values]);
return useMemo(() => (active ? getDialablePhones(fields, values) : []), [active, fields, values]);
}
@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback} from 'react';
import {useDispatch, useStore} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import {createDirectChannel} from 'mattermost-redux/actions/channels';
import {getMyCurrentChannelMembership} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import type {GlobalState} from 'types/store';
/**
* Returns a function that starts a call with the user through the first call plugin,
* creating the DM channel first if it doesn't exist yet.
*/
export default function useStartDMCall(userId: string, dmChannel?: Channel | null) {
const dispatch = useDispatch();
const store = useStore<GlobalState>();
return useCallback(async () => {
let channel = dmChannel;
if (!channel) {
const {data} = await dispatch(createDirectChannel(getCurrentUserId(store.getState()), userId));
channel = data;
}
if (!channel) {
return;
}
const state = store.getState();
state.plugins.components.CallButton?.[0]?.action?.(channel, getMyCurrentChannelMembership(state));
}, [dispatch, store, dmChannel, userId]);
}
@@ -10,6 +10,7 @@ export {MenuItemInput as InputItem} from './menu_item_input';
export {MenuItemLink as LinkItem} from './menu_item_link';
export {MenuItemExternalLink as ItemExternalLink} from './menu_item_external_link';
export {MenuTitle as Title} from './menu_title';
export type {MenuButtonComponentProps} from './menu';
export type {FirstMenuItemProps} from './menu_item';
export {MenuItemSeparator as Separator} from './menu_item_separator';
export {openMenu, dismissMenu} from './menu_utils';
+10 -2
View File
@@ -11,9 +11,12 @@ import React, {
useCallback,
} from 'react';
import type {
ButtonHTMLAttributes,
ComponentType,
ReactNode,
MouseEvent,
KeyboardEvent,
Ref,
} from 'react';
import {useDispatch, useSelector} from 'react-redux';
@@ -39,6 +42,11 @@ export const ELEMENT_ID_FOR_MENU_BACKDROP = 'backdropForMenuComponent';
const MENU_OPEN_ANIMATION_DURATION = 150;
export const MENU_CLOSE_ANIMATION_DURATION = 100;
export type MenuButtonComponentProps = ButtonHTMLAttributes<HTMLButtonElement> & {
ref: Ref<HTMLButtonElement>;
'data-testid'?: string;
};
type MenuButtonProps = {
id: string;
dataTestId?: string;
@@ -46,8 +54,8 @@ type MenuButtonProps = {
'aria-describedby'?: string;
disabled?: boolean;
class?: string;
as?: 'button' | 'div';
children: ReactNode;
as?: 'button' | 'div' | ComponentType<MenuButtonComponentProps>;
children?: ReactNode;
/**
* Opt-in passthrough for callers that need to inspect or adjust the
@@ -275,6 +275,20 @@ $profilePopoverBorderWidth: 1px;
gap: 4px;
justify-items: flex-end;
row-gap: 4px;
.user-popover__call-options-button.btn-icon.btn-sm {
width: auto;
padding: 0 4px 0 7px;
gap: 0;
.icon::before {
margin: 0;
}
.icon-chevron-down {
font-size: 14px;
}
}
}
}
}
@@ -8,17 +8,15 @@ import {useSelector} from 'react-redux';
import {WithTooltip} from '@mattermost/shared/components/tooltip';
import {getChannelByName} from 'mattermost-redux/selectors/entities/channels';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {isSystemAdmin} from 'mattermost-redux/utils/user_utils';
import {
isCallsEnabled as getIsCallsEnabled,
getSessionsInCalls,
getCallsConfig,
callsChannelExplicitlyDisabled,
callsChannelExplicitlyEnabled,
} from 'selectors/calls';
import type {DialablePhone} from 'components/call_options_menu';
import CallOptionsMenu, {useCanStartCall, usePhoneCallOptions, useStartDMCall} from 'components/call_options_menu';
import type {MenuButtonComponentProps} from 'components/menu';
import ProfilePopoverCallButton from 'components/profile_popover/profile_popover_calls_button';
import {getDirectChannelName} from 'utils/utils';
@@ -30,6 +28,7 @@ type Props = {
currentUserId: string;
fullname: string;
username: string;
hide?: () => void;
};
export function isUserInCall(state: GlobalState, userId: string, channelId: string) {
@@ -49,41 +48,13 @@ const CallButton = ({
currentUserId,
fullname,
username,
hide,
}: Props) => {
const {formatMessage} = useIntl();
const isCallsEnabled = useSelector((state: GlobalState) => getIsCallsEnabled(state));
const dmChannel = useSelector((state: GlobalState) => getChannelByName(state, getDirectChannelName(currentUserId, userId)));
const shouldRenderButton = useSelector((state: GlobalState) => {
// 1. No one should get the button if the plugin is disabled.
if (!isCallsEnabled) {
return false;
}
// 2. No one should get the button if calls in channel have been explicitly disabled in the DM channel.
if (callsChannelExplicitlyDisabled(state, dmChannel?.id ?? '')) {
return false;
}
// 3. Admins should get the button unless calls have been explicitly disabled in the DM channel. This
// should apply in test mode as well (DefaultEnabled = false).
if (isSystemAdmin(getUser(state, currentUserId)?.roles)) {
return true;
}
// 4. Users should only see the button if test mode is off (DefaultEnabled = true) and calls in the DM channel are not disabled.
if (getCallsConfig(state).DefaultEnabled) {
return true;
}
// 5. Everyone should see the button if calls have been explicitly enabled in the DM channel, regardless of test mode state.
if (callsChannelExplicitlyEnabled(state, dmChannel?.id ?? '')) {
return true;
}
return false;
});
const shouldRenderButton = useCanStartCall(dmChannel?.id ?? '', currentUserId);
const hasDMCall = useSelector((state: GlobalState) => {
if (isCallsEnabled && dmChannel) {
@@ -92,10 +63,41 @@ const CallButton = ({
return false;
});
// With a phone number for the user, the button opens a menu offering a call or a phone call.
const callButtonAction = useSelector((state: GlobalState) => state.plugins.components?.CallButton?.[0]);
const phoneAction = callButtonAction?.phoneAction;
const phones = usePhoneCallOptions(userId, Boolean(phoneAction) && shouldRenderButton && !hasDMCall);
const startDMCall = useStartDMCall(userId, dmChannel);
function handleStartCall() {
hide?.();
startDMCall();
}
function handlePhoneCall(phone: DialablePhone) {
hide?.();
phoneAction?.({number: phone.number, userId, label: phone.label, fieldId: phone.fieldId});
}
if (!shouldRenderButton) {
return null;
}
if (phoneAction && phones.length > 0) {
return (
<CallOptionsMenu
menuId='profilePopoverCallOptionsMenu'
buttonId='startCallButton'
button={CallOptionsMenuButton}
phones={phones}
onStartCall={handleStartCall}
onPhoneCall={handlePhoneCall}
anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
transformOrigin={{vertical: 'top', horizontal: 'right'}}
/>
);
}
// We disable the button if there's already a call ongoing with the user.
const disabled = hasDMCall;
const startCallMessage = hasDMCall ? formatMessage({
@@ -138,4 +140,21 @@ const CallButton = ({
);
};
const CallOptionsMenuButton = (props: MenuButtonComponentProps) => (
<button
{...props}
type='button'
className='btn btn-icon btn-sm style--none user-popover__call-options-button'
>
<i
className='icon icon-phone'
aria-hidden='true'
/>
<i
className='icon icon-chevron-down'
aria-hidden='true'
/>
</button>
);
export default CallButton;
@@ -3,9 +3,6 @@
import {connect} from 'react-redux';
import {getMyCurrentChannelMembership} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import type {GlobalState} from 'types/store/index';
import ProfilePopoverCallButton from './profile_popover_call_button';
@@ -13,9 +10,7 @@ import ProfilePopoverCallButton from './profile_popover_call_button';
function mapStateToProps(state: GlobalState) {
return {
pluginCallComponents: state.plugins.components.CallButton,
channelMember: getMyCurrentChannelMembership(state),
sidebarOpen: state.views.rhs.isSidebarOpen,
currentUserId: getCurrentUserId(state),
};
}
@@ -2,30 +2,27 @@
// See LICENSE.txt for license information.
import React, {useState, useEffect, useRef, type JSX} from 'react';
import {useDispatch} from 'react-redux';
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import type {Channel} from '@mattermost/types/channels';
import {createDirectChannel} from 'mattermost-redux/actions/channels';
import {useStartDMCall} from 'components/call_options_menu';
import {Constants} from 'utils/constants';
import type {CallButtonAction} from 'types/store/plugins';
type Props = {
channelMember?: ChannelMembership;
pluginCallComponents: CallButtonAction[];
sidebarOpen: boolean;
currentUserId: string;
userId: string;
customButton?: JSX.Element;
dmChannel?: Channel | null;
};
export default function ProfilePopoverCallButton({pluginCallComponents, channelMember, sidebarOpen, customButton, dmChannel, currentUserId, userId}: Props) {
export default function ProfilePopoverCallButton({pluginCallComponents, sidebarOpen, customButton, dmChannel, userId}: Props) {
const [clickEnabled, setClickEnabled] = useState(true);
const prevSidebarOpen = useRef(sidebarOpen);
const dispatch = useDispatch();
const startDMCall = useStartDMCall(userId, dmChannel);
useEffect(() => {
if (prevSidebarOpen.current && !sidebarOpen) {
@@ -41,24 +38,9 @@ export default function ProfilePopoverCallButton({pluginCallComponents, channelM
return null;
}
const getDmChannel = async () => {
if (!dmChannel) {
const {data} = await dispatch(createDirectChannel(currentUserId, userId));
if (data) {
return data;
}
}
return dmChannel;
};
const item = pluginCallComponents[0];
const handleStartCall = async () => {
const channelForCall = await getDmChannel();
item.action?.(channelForCall, channelMember);
};
const clickHandler = async () => {
const clickHandler = () => {
if (clickEnabled) {
handleStartCall();
startDMCall();
}
};
@@ -174,6 +174,7 @@ const ProfilePopoverOtherUserRow = ({
fullname={fullname}
userId={user.id}
username={user.username}
hide={hide}
/>
</div>
</div>
+24
View File
@@ -18511,6 +18511,30 @@
"defaultMessage": "Call type selector",
"description": "Accessibility label for the dropdown in the channel header used to pick which call plugin/type to start."
},
"call_button.start_call": {
"defaultMessage": "Start call",
"description": ""
},
"call_options.aria_label": {
"defaultMessage": "Call options",
"description": ""
},
"call_options.audio": {
"defaultMessage": "Start an Audio Call",
"description": ""
},
"call_options.audio_sub": {
"defaultMessage": "Mattermost Call",
"description": ""
},
"call_options.phone": {
"defaultMessage": "Call {label}",
"description": ""
},
"call_options.phone_sub": {
"defaultMessage": "{number} • Phone call",
"description": ""
},
"carousel.nextButton": {
"defaultMessage": "Next",
"description": "Label of the button that moves to the next slide in a carousel (e.g. trial benefits carousel)."
@@ -4,17 +4,29 @@
import classNames from 'classnames';
import React, {useState, useEffect, useRef} from 'react';
import type {CSSProperties} from 'react';
import {useIntl} from 'react-intl';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import ChevronDownIcon from '@mattermost/compass-icons/components/chevron-down';
import PhoneOutlineIcon from '@mattermost/compass-icons/components/phone-outline';
import PhoneIcon from '@mattermost/compass-icons/components/phone';
import {Button} from '@mattermost/compass-ui/components/button';
import {Icon} from '@mattermost/compass-ui/components/icon';
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getUserIdFromChannelName, isDirectChannel} from 'mattermost-redux/utils/channel_utils';
import {getSessionsInCalls} from 'selectors/calls';
import type {DialablePhone} from 'components/call_options_menu';
import CallOptionsMenu, {useCanStartCall, usePhoneCallOptions} from 'components/call_options_menu';
import type {MenuButtonComponentProps} from 'components/menu';
import Menu from 'components/widgets/menu/menu';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import {Constants} from 'utils/constants';
import type {GlobalState} from 'types/store';
import type {CallButtonAction} from 'types/store/plugins';
import './call_button.scss';
@@ -32,6 +44,17 @@ export default function CallButton({pluginCallComponents, currentChannel, channe
const prevSidebarOpen = useRef(sidebarOpen);
const {formatMessage} = useIntl();
// In a DM with someone who has a phone number, the button opens a menu offering a call or a
// phone call. That needs a single call plugin that can dial, and no call already going on.
const currentUserId = useSelector(getCurrentUserId);
const singleCallButton = pluginCallComponents.length === 1 ? pluginCallComponents[0] : undefined;
const isOpenDM = Boolean(currentChannel && isDirectChannel(currentChannel) && !currentChannel.delete_at);
const dmUserId = isOpenDM && currentChannel ? getUserIdFromChannelName(currentUserId, currentChannel.name) : undefined;
const mayOfferPhone = Boolean(singleCallButton?.phoneAction && dmUserId);
const callOngoing = useSelector((state: GlobalState) => mayOfferPhone && Object.keys(getSessionsInCalls(state)[currentChannel?.id ?? ''] ?? {}).length > 0);
const canCall = useCanStartCall(currentChannel?.id ?? '', currentUserId, mayOfferPhone);
const phones = usePhoneCallOptions(dmUserId, mayOfferPhone && canCall && !callOngoing);
useEffect(() => {
if (prevSidebarOpen.current && !sidebarOpen) {
setClickEnabled(false);
@@ -53,6 +76,35 @@ export default function CallButton({pluginCallComponents, currentChannel, channe
} as CSSProperties,
};
function handleStartCall() {
singleCallButton?.action?.(currentChannel, channelMember);
}
function handlePhoneCall(phone: DialablePhone) {
singleCallButton?.phoneAction?.({number: phone.number, userId: dmUserId ?? '', label: phone.label, fieldId: phone.fieldId});
}
if (singleCallButton?.phoneAction && dmUserId && phones.length > 0) {
return (
<div
style={style.container}
className='flex-child'
>
<CallOptionsMenu
menuId='callOptionsMenu'
buttonId='callOptionsButton'
button={StartCallMenuButton}
disabled={!clickEnabled}
phones={phones}
onStartCall={handleStartCall}
onPhoneCall={handlePhoneCall}
anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
transformOrigin={{vertical: 'top', horizontal: 'right'}}
/>
</div>
);
}
if (pluginCallComponents.length === 1) {
const item = pluginCallComponents[0];
const clickHandler = () => item.action?.(currentChannel, channelMember);
@@ -91,7 +143,7 @@ export default function CallButton({pluginCallComponents, currentChannel, channe
>
<MenuWrapper onToggle={(toggle: boolean) => setActive(toggle)}>
<button className={classNames('style--none call-button dropdown', {active})}>
<PhoneOutlineIcon
<PhoneIcon
color='inherit'
aria-label={formatMessage({id: 'generic_icons.call', defaultMessage: 'Call icon'}).toLowerCase()}
/>
@@ -116,3 +168,18 @@ export default function CallButton({pluginCallComponents, currentChannel, channe
</div>
);
}
const StartCallMenuButton = (props: MenuButtonComponentProps) => (
<Button
{...props}
emphasis='quaternary'
size='small'
leadingIcon={<Icon glyph={<PhoneIcon/>}/>}
trailingIcon={<Icon glyph={<ChevronDownIcon/>}/>}
>
<FormattedMessage
id='call_button.start_call'
defaultMessage='Start call'
/>
</Button>
);
+8
View File
@@ -365,6 +365,10 @@ export default class PluginRegistry {
* - button - A React element to use as the main button to be displayed in case of a single registration.
* - dropdownButton -A React element to use as the dropdown button to be displayed in case of multiple registrations.
* - action - A function called when the button is clicked, passed the channel and channel member as arguments.
* - phoneAction - Optional. A function that places an outbound phone call, passed the number, the user it
* belongs to, and the label and ID of the profile attribute it came from. When given, and the other person
* in a DM or profile popover has a phone number, the call button opens a menu offering a call through
* action or a phone call to each number.
* Returns an unique identifier
* Minimum required version: 6.5
*/
@@ -374,18 +378,21 @@ export default class PluginRegistry {
'action',
'icon',
'dropdownText',
'phoneAction',
], ({
button,
dropdownButton,
action,
icon,
dropdownText,
phoneAction,
}: {
button: ReactResolvable;
dropdownButton: ReactResolvable;
action: CallButtonAction['action'];
icon: ReactResolvable;
dropdownText: ReactResolvable;
phoneAction?: CallButtonAction['phoneAction'];
}) => {
const id = generateId();
@@ -397,6 +404,7 @@ export default class PluginRegistry {
icon: resolveReactElement(icon),
dropdownText: resolveReactElement(dropdownText),
action,
phoneAction,
};
this.dispatchPluginComponentWithData('CallButton', data);
+5
View File
@@ -27,6 +27,11 @@ export function isCallsRingingEnabledOnServer(state: GlobalState) {
return Boolean(pluginState(state)?.callsConfig?.EnableRinging);
}
// isCallsPhoneDialingEnabled reports whether the calls plugin can place outbound phone calls
export function isCallsPhoneDialingEnabled(state: GlobalState) {
return Boolean(pluginState(state)?.callsConfig?.EnableSIPOutbound);
}
export const getSessionsInCalls = createSelector(
'getSessionsInCalls',
pluginState,
@@ -237,10 +237,18 @@ export type UserGuideDropdownAction = PluginComponent & {
action: (fileInfo: FileInfo) => void;
};
export type PhoneCallRequest = {
number: string;
userId: string;
label: string;
fieldId: string;
};
export type CallButtonAction = PluginComponent & {
button: React.ReactNode;
dropdownButton: React.ReactNode;
action: (channel?: Channel | null, member?: ChannelMembership) => void;
phoneAction?: (call: PhoneCallRequest) => void;
};
export type MobileChannelHeaderButtonAction = PluginComponent & {