diff --git a/docs/main/end-user-guide/collaborate/make-calls.mdx b/docs/main/end-user-guide/collaborate/make-calls.mdx index b3548b8b01f..98700761890 100644 --- a/docs/main/end-user-guide/collaborate/make-calls.mdx +++ b/docs/main/end-user-guide/collaborate/make-calls.mdx @@ -65,6 +65,36 @@ On iOS, audio will automatically come through a connected device. You can overri +## 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. + + + +- 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. + + ## Host controls diff --git a/webapp/channels/src/components/call_options_menu/call_options_menu.scss b/webapp/channels/src/components/call_options_menu/call_options_menu.scss new file mode 100644 index 00000000000..aa30d86d358 --- /dev/null +++ b/webapp/channels/src/components/call_options_menu/call_options_menu.scss @@ -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); +} diff --git a/webapp/channels/src/components/call_options_menu/call_options_menu.test.tsx b/webapp/channels/src/components/call_options_menu/call_options_menu.test.tsx new file mode 100644 index 00000000000..6ca8dcb4e57 --- /dev/null +++ b/webapp/channels/src/components/call_options_menu/call_options_menu.test.tsx @@ -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 = {}) => ({ + 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: , + dropdownButton: {'Plugin dropdown button'}, + 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, hide = jest.fn()) => renderWithContext( + , + 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( + , + 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(); + }); +}); diff --git a/webapp/channels/src/components/call_options_menu/call_options_menu.tsx b/webapp/channels/src/components/call_options_menu/call_options_menu.tsx new file mode 100644 index 00000000000..a88d4f83e03 --- /dev/null +++ b/webapp/channels/src/components/call_options_menu/call_options_menu.tsx @@ -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; + 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 ( + + } + labels={ + <> + + + + + + + + } + onClick={onStartCall} + /> + {phones.map((phone) => ( + } + labels={ + <> + + + + + + + + } + onClick={() => onPhoneCall(phone)} + /> + ))} + + ); +}; + +export default CallOptionsMenu; diff --git a/webapp/channels/src/components/call_options_menu/index.ts b/webapp/channels/src/components/call_options_menu/index.ts new file mode 100644 index 00000000000..1dd505d5e52 --- /dev/null +++ b/webapp/channels/src/components/call_options_menu/index.ts @@ -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'; diff --git a/webapp/channels/src/components/call_options_menu/use_can_start_call.ts b/webapp/channels/src/components/call_options_menu/use_can_start_call.ts new file mode 100644 index 00000000000..418a9e3b23c --- /dev/null +++ b/webapp/channels/src/components/call_options_menu/use_can_start_call.ts @@ -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)); +} diff --git a/webapp/channels/src/components/call_options_menu/use_phone_call_options.ts b/webapp/channels/src/components/call_options_menu/use_phone_call_options.ts new file mode 100644 index 00000000000..cf75408ecb8 --- /dev/null +++ b/webapp/channels/src/components/call_options_menu/use_phone_call_options.ts @@ -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]); +} diff --git a/webapp/channels/src/components/call_options_menu/use_start_dm_call.ts b/webapp/channels/src/components/call_options_menu/use_start_dm_call.ts new file mode 100644 index 00000000000..b0ebfe22550 --- /dev/null +++ b/webapp/channels/src/components/call_options_menu/use_start_dm_call.ts @@ -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(); + + 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]); +} diff --git a/webapp/channels/src/components/menu/index.ts b/webapp/channels/src/components/menu/index.ts index b68b410c284..0ca9f4d414c 100644 --- a/webapp/channels/src/components/menu/index.ts +++ b/webapp/channels/src/components/menu/index.ts @@ -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'; diff --git a/webapp/channels/src/components/menu/menu.tsx b/webapp/channels/src/components/menu/menu.tsx index 868d630ad4b..a6b7848e6b4 100644 --- a/webapp/channels/src/components/menu/menu.tsx +++ b/webapp/channels/src/components/menu/menu.tsx @@ -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 & { + ref: Ref; + '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; + children?: ReactNode; /** * Opt-in passthrough for callers that need to inspect or adjust the diff --git a/webapp/channels/src/components/profile_popover/profile_popover.scss b/webapp/channels/src/components/profile_popover/profile_popover.scss index ca712b7b33b..3566fc96754 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover.scss +++ b/webapp/channels/src/components/profile_popover/profile_popover.scss @@ -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; + } + } } } } diff --git a/webapp/channels/src/components/profile_popover/profile_popover_call_button_wrapper/index.tsx b/webapp/channels/src/components/profile_popover/profile_popover_call_button_wrapper/index.tsx index 29ddb82ecdf..dec11d115b1 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover_call_button_wrapper/index.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover_call_button_wrapper/index.tsx @@ -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 ( + + ); + } + // 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) => ( +