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 (
+
+
+ );
+};
+
+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) => (
+
+);
+
export default CallButton;
diff --git a/webapp/channels/src/components/profile_popover/profile_popover_calls_button/index.tsx b/webapp/channels/src/components/profile_popover/profile_popover_calls_button/index.tsx
index 3b762042f6c..57b354458e6 100644
--- a/webapp/channels/src/components/profile_popover/profile_popover_calls_button/index.tsx
+++ b/webapp/channels/src/components/profile_popover/profile_popover_calls_button/index.tsx
@@ -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),
};
}
diff --git a/webapp/channels/src/components/profile_popover/profile_popover_calls_button/profile_popover_call_button.tsx b/webapp/channels/src/components/profile_popover/profile_popover_calls_button/profile_popover_call_button.tsx
index d0bd8079735..ee7ac0cfee1 100644
--- a/webapp/channels/src/components/profile_popover/profile_popover_calls_button/profile_popover_call_button.tsx
+++ b/webapp/channels/src/components/profile_popover/profile_popover_calls_button/profile_popover_call_button.tsx
@@ -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();
}
};
diff --git a/webapp/channels/src/components/profile_popover/profile_popover_other_user_row.tsx b/webapp/channels/src/components/profile_popover/profile_popover_other_user_row.tsx
index b577ea2fa39..af86b740659 100644
--- a/webapp/channels/src/components/profile_popover/profile_popover_other_user_row.tsx
+++ b/webapp/channels/src/components/profile_popover/profile_popover_other_user_row.tsx
@@ -174,6 +174,7 @@ const ProfilePopoverOtherUserRow = ({
fullname={fullname}
userId={user.id}
username={user.username}
+ hide={hide}
/>
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json
index e6cb31d6a42..3f6dea5fdc6 100644
--- a/webapp/channels/src/i18n/en.json
+++ b/webapp/channels/src/i18n/en.json
@@ -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)."
diff --git a/webapp/channels/src/plugins/call_button/call_button.tsx b/webapp/channels/src/plugins/call_button/call_button.tsx
index de290d14101..366059ffbc7 100644
--- a/webapp/channels/src/plugins/call_button/call_button.tsx
+++ b/webapp/channels/src/plugins/call_button/call_button.tsx
@@ -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 (
+
+
+
+ );
+ }
+
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
>
setActive(toggle)}>