MM-64531: [Shared Channels] Users on different remote servers should not communicate unless the remotes have established secure connection. (#30985) (#33434)
Automatic Merge
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
3a6aeee57e
Коммит
07a34f02b6
@@ -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 they’re 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) {
|
||||
|
||||
Ссылка в новой задаче
Block a user