mirror of
https://github.com/mattermost/mattermost.git
synced 2026-10-13 09:27:38 +08:00
[MM-71076] Calls: Add phone call options menu to the call button in DMs and profile popover (#38941)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
-35
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+6
-24
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
Reference in New Issue
Block a user