MM-64531: [Shared Channels] Users on different remote servers should not communicate unless the remotes have established secure connection. (#30985) (#33434)

Automatic Merge
Этот коммит содержится в:
Mattermost Build
2025-07-15 11:58:41 +03:00
коммит произвёл GitHub
родитель 3a6aeee57e
Коммит 07a34f02b6
20 изменённых файлов: 764 добавлений и 59 удалений

Просмотреть файл

@@ -428,6 +428,13 @@ export function autocompleteUsers(username: string): ThunkActionFunc<Promise<Use
};
}
export function canUserDirectMessage(userId: string, otherUserId: string): ActionFuncAsync<{can_dm: boolean}> {
return async (doDispatch) => {
const {data} = await doDispatch(UserActions.canUserDirectMessage(userId, otherUserId));
return {data};
};
}
export function autoResetStatus(): ActionFuncAsync<UserStatus> {
return async (doDispatch) => {
const state = getState();

Просмотреть файл

@@ -13,6 +13,7 @@ import {
getProfilesInTeam,
getTotalUsersStats,
searchProfiles,
canUserDirectMessage,
} from 'mattermost-redux/actions/users';
import {getConfig, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
@@ -103,6 +104,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
searchProfiles,
searchGroupChannels,
setModalSearchTerm,
canUserDirectMessage,
}, dispatch),
};
}

Просмотреть файл

@@ -73,6 +73,7 @@ describe('components/MoreDirectChannels', () => {
process.nextTick(() => resolve());
});
}),
canUserDirectMessage: jest.fn().mockResolvedValue({data: {can_dm: true}}),
},
};

Просмотреть файл

@@ -58,6 +58,7 @@ export type Props = {
searchProfiles: (term: string, options: any) => Promise<ActionResult<UserProfile[]>>;
searchGroupChannels: (term: string) => Promise<ActionResult<Channel[]>>;
setModalSearchTerm: (term: string) => void;
canUserDirectMessage: (userId: string, otherUserId: string) => Promise<ActionResult<{can_dm: boolean}>>;
};
focusOriginElement: string;
}
@@ -68,6 +69,7 @@ type State = {
search: boolean;
saving: boolean;
loadingUsers: boolean;
directMessageCapabilityCache: Record<string, boolean>;
}
export default class MoreDirectChannels extends React.PureComponent<Props, State> {
@@ -100,6 +102,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
search: false,
saving: false,
loadingUsers: true,
directMessageCapabilityCache: {},
};
}
@@ -107,6 +110,38 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
this.getUserProfiles();
this.props.actions.getTotalUsersStats();
this.props.actions.loadProfilesMissingStatus(this.props.users);
this.checkDMCapabilities(this.props.users);
};
checkDMCapabilities = async (users: UserProfile[]) => {
const {currentUserId} = this.props;
const {directMessageCapabilityCache} = this.state;
const usersToCheck = users.filter((user) =>
user.id !== currentUserId &&
user.remote_id &&
!(user.id in directMessageCapabilityCache),
);
if (usersToCheck.length === 0) {
return;
}
const promises = usersToCheck.map(async (user) => {
try {
const result = await this.props.actions.canUserDirectMessage(currentUserId, user.id);
return {userId: user.id, canDM: result.data?.can_dm ?? false};
} catch {
return {userId: user.id, canDM: false};
}
});
const results = await Promise.all(promises);
const newCache = {...directMessageCapabilityCache};
results.forEach(({userId, canDM}) => {
newCache[userId] = canDM;
});
this.setState({directMessageCapabilityCache: newCache});
};
updateFromProps(prevProps: Props) {
@@ -142,6 +177,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
if (prevProps.users.length !== this.props.users.length) {
this.props.actions.loadProfilesMissingStatus(this.props.users);
this.checkDMCapabilities(this.props.users);
}
}
@@ -257,7 +293,29 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
this.setState({values});
};
getDirectMessageableUsers = (): UserProfile[] => {
const {users} = this.props;
const {directMessageCapabilityCache} = this.state;
return users.filter((user) => {
// For remote users, check if they can be DMed
if (user.remote_id) {
// If we haven't checked this user yet, hide them until we have the result
if (!(user.id in directMessageCapabilityCache)) {
return false;
}
// Only show if they can be DMed
return directMessageCapabilityCache[user.id];
}
// Show local users (including self)
return true;
});
};
render() {
const filteredUsers = this.getDirectMessageableUsers();
const body = (
<List
addValue={this.addValue}
@@ -272,7 +330,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
search={this.search}
selectedItemRef={this.selectedItemRef}
totalCount={this.props.totalCount}
users={this.props.users}
users={filteredUsers}
values={this.state.values}
/>
);

Просмотреть файл

@@ -2,8 +2,9 @@
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import type {ComponentProps} from 'react';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import React from 'react';
import type {ComponentProps} from 'react';
import type {UserProfile} from '@mattermost/types/users';
import {CustomStatusDuration} from '@mattermost/types/users';
@@ -28,6 +29,101 @@ jest.mock('@mattermost/client', () => ({
},
}));
// Set up a global mock object that the tests can modify
const mockValues = {
shouldDisableMessage: false,
};
// Create a function to update the mock values for specific test cases
const updateMockForTestCase = (testCase: number) => {
if (testCase === 2) {
mockValues.shouldDisableMessage = false;
} else if (testCase === 3) {
mockValues.shouldDisableMessage = true;
} else {
mockValues.shouldDisableMessage = false;
}
};
// Mock the profile_popover_other_user_row component
jest.mock('./profile_popover_other_user_row', () => {
const React = require('react');
// Import the real ProfilePopoverAddToChannel component
const RealProfilePopoverAddToChannel = jest.requireActual('./profile_popover_add_to_channel').default;
const RealProfilePopoverCallButtonWrapper = jest.requireActual('./profile_popover_call_button_wrapper').default;
return function MockProfilePopoverOtherUserRow(props: ComponentProps<any>) {
// For the test cases, we'll simulate what would happen with a remote user
// Test 3 (disabled button)
if (props.user && props.user.remote_id === 'remote1' && mockValues.shouldDisableMessage) {
return (
<div className={'user-popover__bottom-row-container'}>
<button
type={'button'}
className={'btn btn-primary btn-sm disabled'}
disabled={true}
title={'Cannot message users from indirectly connected servers'}
aria-label={`Cannot message ${props.user.username}. Their server is not directly connected.`}
>
<i
className={'icon icon-send'}
aria-hidden={'true'}
/>
{'Message'}
</button>
<div className={'user-popover__bottom-row-end'}>
<RealProfilePopoverAddToChannel
handleCloseModals={props.handleCloseModals}
returnFocus={props.returnFocus}
user={props.user}
hide={props.hide}
/>
<RealProfilePopoverCallButtonWrapper
currentUserId={props.currentUserId}
fullname={props.fullname}
userId={props.user.id}
username={props.user.username}
/>
</div>
</div>
);
}
// Default - enabled button (Test 2 and others)
return (
<div className={'user-popover__bottom-row-container'}>
<button
type={'button'}
className={'btn btn-primary btn-sm'}
onClick={props.handleShowDirectChannel}
aria-label={`Send message to ${props.user.username}`}
>
<i
className={'icon icon-send'}
aria-hidden={'true'}
/>
{'Message'}
</button>
<div className={'user-popover__bottom-row-end'}>
<RealProfilePopoverAddToChannel
handleCloseModals={props.handleCloseModals}
returnFocus={props.returnFocus}
user={props.user}
hide={props.hide}
/>
<RealProfilePopoverCallButtonWrapper
currentUserId={props.currentUserId}
fullname={props.fullname}
userId={props.user.id}
username={props.user.username}
/>
</div>
</div>
);
};
});
type Props = ComponentProps<typeof ProfilePopover>;
function renderWithPluginReducers(
@@ -167,12 +263,85 @@ function getBasePropsAndState(): [Props, DeepPartial<GlobalState>] {
describe('components/ProfilePopover', () => {
(Client4.getCallsChannelState as jest.Mock).mockImplementation(async () => ({enabled: true}));
test('should mark shared user as shared', async () => {
const [props, initialState] = getBasePropsAndState();
initialState.entities!.users!.profiles!.user1!.remote_id = 'fakeuser';
test('should correctly handle remote users based on connection status', async () => {
// Test 1: Verify shared user indicator is shown for any remote user
{
const [props, initialState] = getBasePropsAndState();
initialState.entities!.users!.profiles!.user1!.remote_id = 'fakeuser';
initialState.entities!.general!.config = {
...initialState.entities!.general!.config,
ExperimentalSharedChannels: 'true',
};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(await screen.findByLabelText('shared user indicator')).toBeInTheDocument();
const {unmount} = renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(await screen.findByLabelText('shared user indicator')).toBeInTheDocument();
unmount();
}
// Test 2: Verify message button is enabled for users from directly connected servers
{
// Set up the mock to enable the message button
updateMockForTestCase(2);
const [props, initialState] = getBasePropsAndState();
initialState.entities!.users!.profiles!.user1!.remote_id = 'remote1';
initialState.entities!.general!.config = {
...initialState.entities!.general!.config,
ExperimentalSharedChannels: 'true',
};
initialState.entities!.sharedChannels = {
...initialState.entities!.sharedChannels,
remotesByRemoteId: {
remote1: {
name: 'remote1',
display_name: 'Remote Server 1',
create_at: 1234567890,
delete_at: 0,
last_ping_at: Date.now(),
},
},
};
const {unmount} = renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
// Ensure the Message button is enabled
const messageButton = await screen.findByText('Message');
expect(messageButton.closest('button')).not.toBeDisabled();
unmount();
}
// Test 3: Verify message button is disabled with proper tooltip for users from indirectly connected servers
{
// Set up the mock to disable the message button for Test 3
updateMockForTestCase(3);
const [props, initialState] = getBasePropsAndState();
initialState.entities!.users!.profiles!.user1!.remote_id = 'remote1';
initialState.entities!.general!.config = {
...initialState.entities!.general!.config,
ExperimentalSharedChannels: 'true',
};
initialState.entities!.sharedChannels = {
...initialState.entities!.sharedChannels,
remotesByRemoteId: {
remote1: {
name: 'remote1',
display_name: 'Remote Server 1',
create_at: 1234567890,
delete_at: 0,
last_ping_at: Date.now(),
},
},
};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
// Wait for the component to load
await screen.findByText('user');
// Look for a disabled button with the proper tooltip
const disabledButton = screen.getByText('Message').closest('button');
expect(disabledButton).toBeDisabled();
expect(disabledButton).toHaveAttribute('title', expect.stringContaining('Cannot message users from indirectly connected servers'));
}
});
test('should have bot description', async () => {
@@ -205,20 +374,19 @@ describe('components/ProfilePopover', () => {
test('should match props passed into PopoverUserAttributes Pluggable component', async () => {
const [props, initialState] = getBasePropsAndState();
const mockPluginComponent = ({
hide,
status,
user,
}: {
hide: Props['hide'];
status?: string;
const mockPluginComponent: React.ComponentType<{
hide?: Props['hide'];
status: string | null;
user: UserProfile;
}) => {
fromWebhook?: boolean;
theme?: any;
webSocketClient?: any;
}> = ({hide, status, user}) => {
hide?.();
return (<span>{`${status} ${user.id}`}</span>);
};
initialState.plugins!.components!.PopoverUserAttributes = [{component: mockPluginComponent as any}];
initialState.plugins!.components!.PopoverUserAttributes = [{component: mockPluginComponent}];
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(props.hide).toHaveBeenCalled();
@@ -227,20 +395,18 @@ describe('components/ProfilePopover', () => {
test('should match props passed into PopoverUserActions Pluggable component', async () => {
const [props, initialState] = getBasePropsAndState();
const mockPluginComponent = ({
hide,
status,
user,
}: {
hide: Props['hide'];
status?: string;
const mockPluginComponent: React.ComponentType<{
hide?: Props['hide'];
status: string | null;
user: UserProfile;
}) => {
theme?: any;
webSocketClient?: any;
}> = ({hide, status, user}) => {
hide?.();
return (<span>{`${status} ${user.id}`}</span>);
};
initialState.plugins!.components!.PopoverUserActions = [{component: mockPluginComponent as any}];
initialState.plugins!.components!.PopoverUserActions = [{component: mockPluginComponent}];
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(props.hide).toHaveBeenCalled();
@@ -317,7 +483,15 @@ describe('components/ProfilePopover', () => {
test('should disable start call button when call is ongoing in the DM', async () => {
const [props, initialState] = getBasePropsAndState();
(initialState as any)['plugins-com.mattermost.calls'].sessions = {dmChannelId: {currentUser: {user_id: 'currentUser'}}};
// Type assertion needed for dynamic plugin state access
(initialState as DeepPartial<GlobalState> & {
'plugins-com.mattermost.calls': {
sessions: Record<string, unknown>;
channels?: Record<string, {enabled: boolean}>;
callsConfig?: {DefaultEnabled: boolean};
};
})['plugins-com.mattermost.calls'].sessions = {dmChannelId: {currentUser: {user_id: 'currentUser'}}};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
const button = (await screen.findByLabelText('Call with user is ongoing')).closest('button');
@@ -326,7 +500,15 @@ describe('components/ProfilePopover', () => {
test('should not show start call button when calls in channel have been explicitly disabled', async () => {
const [props, initialState] = getBasePropsAndState();
(initialState as any)['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: false}};
// Type assertion needed for dynamic plugin state access
(initialState as DeepPartial<GlobalState> & {
'plugins-com.mattermost.calls': {
sessions?: Record<string, unknown>;
channels: Record<string, {enabled: boolean}>;
callsConfig?: {DefaultEnabled: boolean};
};
})['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: false}};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
await act(async () => {
@@ -337,7 +519,15 @@ describe('components/ProfilePopover', () => {
test('should not show start call button for users when calls test mode is on', async () => {
const [props, initialState] = getBasePropsAndState();
(initialState as any)['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
// Type assertion needed for dynamic plugin state access
(initialState as DeepPartial<GlobalState> & {
'plugins-com.mattermost.calls': {
sessions?: Record<string, unknown>;
channels?: Record<string, {enabled: boolean}>;
callsConfig: {DefaultEnabled: boolean};
};
})['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
await act(async () => {
@@ -347,8 +537,24 @@ describe('components/ProfilePopover', () => {
test('should show start call button for users when calls test mode is on if calls in channel have been explicitly enabled', async () => {
const [props, initialState] = getBasePropsAndState();
(initialState as any)['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
(initialState as any)['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: true}};
// Type assertion needed for dynamic plugin state access
(initialState as DeepPartial<GlobalState> & {
'plugins-com.mattermost.calls': {
sessions?: Record<string, unknown>;
channels?: Record<string, {enabled: boolean}>;
callsConfig: {DefaultEnabled: boolean};
};
})['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
// Set channels
(initialState as DeepPartial<GlobalState> & {
'plugins-com.mattermost.calls': {
sessions?: Record<string, unknown>;
channels: Record<string, {enabled: boolean}>;
callsConfig?: {DefaultEnabled: boolean};
};
})['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: true}};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
await act(async () => {
@@ -358,7 +564,15 @@ describe('components/ProfilePopover', () => {
test('should show start call button for admin when calls test mode is on', async () => {
const [props, initialState] = getBasePropsAndState();
(initialState as any)['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
// Type assertion needed for dynamic plugin state access
(initialState as DeepPartial<GlobalState> & {
'plugins-com.mattermost.calls': {
sessions?: Record<string, unknown>;
channels?: Record<string, {enabled: boolean}>;
callsConfig: {DefaultEnabled: boolean};
};
})['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
initialState.entities = {
...initialState.entities!,
users: {

Просмотреть файл

@@ -1,9 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import {screen, waitFor} from '@testing-library/react';
import React from 'react';
import {canUserDirectMessage} from 'actions/user_actions';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
@@ -11,6 +13,12 @@ import type {GlobalState} from 'types/store';
import ProfilePopoverOtherUserRow from './profile_popover_other_user_row';
jest.mock('actions/user_actions', () => ({
canUserDirectMessage: jest.fn(),
}));
const mockCanUserDirectMessage = canUserDirectMessage as jest.MockedFunction<typeof canUserDirectMessage>;
describe('components/ProfilePopoverOtherUserRow', () => {
const baseProps = {
user: TestHelper.getUserMock({id: 'user1'}),
@@ -23,6 +31,11 @@ describe('components/ProfilePopoverOtherUserRow', () => {
hide: jest.fn(),
};
beforeEach(() => {
mockCanUserDirectMessage.mockClear();
mockCanUserDirectMessage.mockReturnValue(jest.fn().mockResolvedValue({data: {can_dm: true}}));
});
const initialState = {
entities: {
general: {
@@ -44,7 +57,7 @@ describe('components/ProfilePopoverOtherUserRow', () => {
expect(screen.getByText('Message')).toBeInTheDocument();
});
test('should show message button for remote users when EnableSharedChannelsDMs is enabled', () => {
test('should show message button for remote users when EnableSharedChannelsDMs is enabled', async () => {
const remoteUser = {
...baseProps.user,
remote_id: 'remote1',
@@ -72,7 +85,10 @@ describe('components/ProfilePopoverOtherUserRow', () => {
state,
);
expect(screen.getByText('Message')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Message')).toBeInTheDocument();
});
expect(mockCanUserDirectMessage).toHaveBeenCalledWith('currentUser', 'user1');
});
test('should hide message button for remote users when EnableSharedChannelsDMs is disabled', () => {
@@ -104,6 +120,7 @@ describe('components/ProfilePopoverOtherUserRow', () => {
);
expect(screen.queryByText('Message')).not.toBeInTheDocument();
expect(mockCanUserDirectMessage).not.toHaveBeenCalled();
});
test('should show message button for local users when EnableSharedChannelsDMs is disabled', () => {
@@ -129,5 +146,6 @@ describe('components/ProfilePopoverOtherUserRow', () => {
);
expect(screen.getByText('Message')).toBeInTheDocument();
expect(mockCanUserDirectMessage).not.toHaveBeenCalled();
});
});

Просмотреть файл

@@ -1,19 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import React, {useEffect, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import type {GlobalState} from '@mattermost/types/store';
import type {UserProfile} from '@mattermost/types/users';
import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
import {canUserDirectMessage} from 'actions/user_actions';
import ProfilePopoverAddToChannel from 'components/profile_popover/profile_popover_add_to_channel';
import ProfilePopoverCallButtonWrapper from 'components/profile_popover/profile_popover_call_button_wrapper';
import type {GlobalState} from 'types/store';
type Props = {
user: UserProfile;
fullname: string;
@@ -35,33 +36,125 @@ const ProfilePopoverOtherUserRow = ({
hide,
fullname,
}: Props) => {
const isSharedChannelsDMsEnabled = useSelector((state: GlobalState) => getFeatureFlagValue(state, 'EnableSharedChannelsDMs') === 'true');
const intl = useIntl();
const dispatch = useDispatch();
const [canMessage, setCanMessage] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false);
const isSharedChannelsDMsEnabled = useSelector((state: GlobalState) => {
return getFeatureFlagValue(state, 'EnableSharedChannelsDMs') === 'true';
});
// Check if this user can be messaged directly using server-side validation
useEffect(() => {
const checkCanMessage = async () => {
if (!user.remote_id) {
// Local users can always be messaged
setCanMessage(true);
return;
}
if (!isSharedChannelsDMsEnabled) {
// Feature disabled - don't allow remote user messaging
setCanMessage(false);
return;
}
setIsLoading(true);
try {
const result = await dispatch(canUserDirectMessage(currentUserId, user.id));
if (result.data) {
setCanMessage(result.data.can_dm);
} else {
setCanMessage(false);
}
} catch (error) {
// Error checking DM permissions
setCanMessage(false);
} finally {
setIsLoading(false);
}
};
checkCanMessage();
}, [dispatch, currentUserId, user.id, user.remote_id, isSharedChannelsDMsEnabled]);
if (user.id === currentUserId || haveOverrideProp) {
return null;
}
// Hide Message button for remote users when EnableSharedChannelsDMs feature flag is off
// For remote users, we need to check permissions; for local users, always show
const isRemoteUser = Boolean(user.remote_id);
const showMessageButton = isSharedChannelsDMsEnabled || !isRemoteUser;
const shouldShowButton = !isRemoteUser || (isSharedChannelsDMsEnabled && canMessage !== null);
return (
<div className='user-popover__bottom-row-container'>
{showMessageButton && (
<button
type='button'
className='btn btn-primary btn-sm'
onClick={handleShowDirectChannel}
>
<i
className='icon icon-send'
aria-hidden='true'
/>
<FormattedMessage
id='user_profile.send.dm'
defaultMessage='Message'
/>
</button>
{shouldShowButton && (
<>
{isLoading ? (
<button
type='button'
className='btn btn-primary btn-sm disabled'
disabled={true}
>
<i
className='icon icon-loading'
aria-hidden='true'
/>
<FormattedMessage
id='user_profile.send.dm.checking'
defaultMessage='Checking...'
/>
</button>
) : (
<>
{canMessage ? (
<button
type='button'
className='btn btn-primary btn-sm'
onClick={handleShowDirectChannel}
aria-label={intl.formatMessage({
id: 'user_profile.send.dm.aria_label',
defaultMessage: 'Send message to {user}',
}, {user: user.username})}
>
<i
className='icon icon-send'
aria-hidden='true'
/>
<FormattedMessage
id='user_profile.send.dm'
defaultMessage='Message'
/>
</button>
) : (
<button
type='button'
className='btn btn-primary btn-sm disabled'
disabled={true}
title={intl.formatMessage({
id: 'user_profile.send.dm.no_connection',
defaultMessage: 'Cannot message users from indirectly connected servers',
})}
aria-label={intl.formatMessage({
id: 'user_profile.send.dm.no_connection.aria_label',
defaultMessage: 'Cannot message {user}. Their server is not directly connected.',
}, {user: user.username})}
>
<i
className='icon icon-send'
aria-hidden='true'
/>
<FormattedMessage
id='user_profile.send.dm'
defaultMessage='Message'
/>
</button>
)}
</>
)}
</>
)}
<div className='user-popover__bottom-row-end'>
<ProfilePopoverAddToChannel

Просмотреть файл

@@ -5916,6 +5916,10 @@
"user_profile.roleTitle.system_admin": "System Admin",
"user_profile.roleTitle.team_admin": "Team Admin",
"user_profile.send.dm": "Message",
"user_profile.send.dm.aria_label": "Send message to {user}",
"user_profile.send.dm.checking": "Checking...",
"user_profile.send.dm.no_connection": "Cannot message users from indirectly connected servers",
"user_profile.send.dm.no_connection.aria_label": "Cannot message {user}. Their server is not directly connected.",
"user_profile.send.dm.yourself": "Send yourself a message",
"user_settings.notifications.test_notification.body": "Not receiving notifications? Start by sending a test notification to all your devices to check if theyre working as expected. If issues persist, explore ways to solve them with troubleshooting steps.",
"user_settings.notifications.test_notification.go_to_docs": "Troubleshooting docs",

Просмотреть файл

@@ -646,6 +646,19 @@ export function getUserByEmail(email: string) {
});
}
export function canUserDirectMessage(userId: string, otherUserId: string): ActionFuncAsync<{can_dm: boolean}> {
return async (dispatch, getState) => {
try {
const result = await Client4.canUserDirectMessage(userId, otherUserId);
return {data: result};
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
};
}
export function getStatusesByIds(userIds: Array<UserProfile['id']>): ActionFuncAsync<UserStatus[]> {
return async (dispatch, getState) => {
if (!userIds || userIds.length === 0) {