Migrate profile popover from class to functional component (#25608)

* Migrate profile popover from class to functional component

* Fix lint

* Fix tests and minor fixes

* Address feedback

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Daniel Espino García
2024-01-15 09:28:43 +01:00
коммит произвёл GitHub
родитель 23fcb7728f
Коммит 77bdeb1c2b
52 изменённых файлов: 1826 добавлений и 5992 удалений

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

@@ -149,7 +149,7 @@ exports[`components/AtMention should match snapshot when mentioning current user
rootClose={true}
show={false}
>
<Connect(injectIntl(ProfilePopover))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/abc1/image"
@@ -189,7 +189,7 @@ exports[`components/AtMention should match snapshot when mentioning user 1`] = `
rootClose={true}
show={false}
>
<Connect(injectIntl(ProfilePopover))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/abc2/image"
@@ -229,7 +229,7 @@ exports[`components/AtMention should match snapshot when mentioning user contain
rootClose={true}
show={false}
>
<Connect(injectIntl(ProfilePopover))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/abc3/image"
@@ -270,7 +270,7 @@ exports[`components/AtMention should match snapshot when mentioning user contain
rootClose={true}
show={false}
>
<Connect(injectIntl(ProfilePopover))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/abc3/image"
@@ -310,7 +310,7 @@ exports[`components/AtMention should match snapshot when mentioning user followe
rootClose={true}
show={false}
>
<Connect(injectIntl(ProfilePopover))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/abc2/image"
@@ -351,7 +351,7 @@ exports[`components/AtMention should match snapshot when mentioning user with di
rootClose={true}
show={false}
>
<Connect(injectIntl(ProfilePopover))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/abc2/image"
@@ -391,7 +391,7 @@ exports[`components/AtMention should match snapshot when mentioning user with mi
rootClose={true}
show={false}
>
<Connect(injectIntl(ProfilePopover))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/abc2/image"

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

@@ -31,7 +31,6 @@ type Props = {
groupsByName: Record<string, Group>;
children?: React.ReactNode;
channelId?: string;
hasMention?: boolean;
disableHighlight?: boolean;
disableGroupHighlight?: boolean;
}
@@ -122,7 +121,6 @@ export const AtMention = (props: Props) => {
className='user-profile-popover'
userId={user.id}
src={Client4.getProfilePictureUrl(user.id, user.last_picture_update)}
hasMention={props.hasMention}
hide={hideOverlay}
channelId={props.channelId}
/>
@@ -187,7 +185,6 @@ export const AtMention = (props: Props) => {
userId={groupUser.id}
src={Client4.getProfilePictureUrl(groupUser.id, groupUser.last_picture_update)}
channelId={props.channelId}
hasMention={props.hasMention}
hide={hideGroupUserOverlay}
returnFocus={returnFocus}
/>

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

@@ -48,8 +48,7 @@ function CustomStatusEmoji({
const customStatusExpired = useSelector((state: GlobalState) => isCustomStatusExpired(state, customStatus));
const customStatusEnabled = useSelector(isCustomStatusEnabled);
const isCustomStatusSet = Boolean(customStatusEnabled && customStatus?.emoji && !customStatusExpired);
if (!isCustomStatusSet) {
if (!customStatusEnabled || !customStatus?.emoji || customStatusExpired) {
return null;
}

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

@@ -44,10 +44,9 @@ exports[`components/drafts/panel/panel_body should have called handleFormattedTe
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
channelId="channel_id"
className="user-profile-popover"
hasMention={false}
hide={[Function]}
src="/api/v4/users/user_id/image?_=0"
userId="user_id"
@@ -67,7 +66,6 @@ exports[`components/drafts/panel/panel_body should have called handleFormattedTe
<OverlayWrapper
channelId="channel_id"
className="user-profile-popover"
hasMention={false}
hide={[Function]}
intl={
Object {
@@ -317,10 +315,9 @@ exports[`components/drafts/panel/panel_body should match snapshot 1`] = `
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
channelId="channel_id"
className="user-profile-popover"
hasMention={false}
hide={[Function]}
src="/api/v4/users/user_id/image?_=0"
userId="user_id"
@@ -340,7 +337,6 @@ exports[`components/drafts/panel/panel_body should match snapshot 1`] = `
<OverlayWrapper
channelId="channel_id"
className="user-profile-popover"
hasMention={false}
hide={[Function]}
intl={
Object {
@@ -596,10 +592,9 @@ exports[`components/drafts/panel/panel_body should match snapshot for priority 1
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
channelId="channel_id"
className="user-profile-popover"
hasMention={false}
hide={[Function]}
src="/api/v4/users/user_id/image?_=0"
userId="user_id"
@@ -619,7 +614,6 @@ exports[`components/drafts/panel/panel_body should match snapshot for priority 1
<OverlayWrapper
channelId="channel_id"
className="user-profile-popover"
hasMention={false}
hide={[Function]}
intl={
Object {
@@ -937,10 +931,9 @@ exports[`components/drafts/panel/panel_body should match snapshot for requested_
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
channelId="channel_id"
className="user-profile-popover"
hasMention={false}
hide={[Function]}
src="/api/v4/users/user_id/image?_=0"
userId="user_id"
@@ -960,7 +953,6 @@ exports[`components/drafts/panel/panel_body should match snapshot for requested_
<OverlayWrapper
channelId="channel_id"
className="user-profile-popover"
hasMention={false}
hide={[Function]}
intl={
Object {

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

@@ -3,7 +3,7 @@
import React from 'react';
import type {ReactNode} from 'react';
import {FormattedMessage} from 'react-intl';
import {FormattedMessage, useIntl} from 'react-intl';
import type {Post} from '@mattermost/types/posts';
@@ -28,6 +28,7 @@ type Props = {
};
const PostUserProfile = (props: Props): JSX.Element | null => {
const intl = useIntl();
const {post, compactDisplay, isMobileView, isConsecutivePost, enablePostUsernameOverride, isBot, isSystemMessage, colorizeUsernames} = props;
const isFromAutoResponder = fromAutoResponder(post);
const colorize = compactDisplay && colorizeUsernames;
@@ -55,7 +56,6 @@ const PostUserProfile = (props: Props): JSX.Element | null => {
userId={post.user_id}
channelId={post.channel_id}
isRHS={isRHS}
hasMention={true}
colorize={colorize}
/>
);
@@ -67,7 +67,6 @@ const PostUserProfile = (props: Props): JSX.Element | null => {
userId={post.user_id}
channelId={post.channel_id}
isRHS={isRHS}
hasMention={true}
colorize={colorize}
/>
);
@@ -77,7 +76,6 @@ const PostUserProfile = (props: Props): JSX.Element | null => {
userId={post.user_id}
channelId={post.channel_id}
isRHS={isRHS}
hasMention={true}
colorize={colorize}
/>
);
@@ -108,7 +106,6 @@ const PostUserProfile = (props: Props): JSX.Element | null => {
channelId={post.channel_id}
hideStatus={true}
isRHS={isRHS}
hasMention={true}
colorize={colorize}
/>
</span>
@@ -135,12 +132,10 @@ const PostUserProfile = (props: Props): JSX.Element | null => {
} else if (isSystemMessage) {
userProfile = (
<UserProfile
overwriteName={
<FormattedMessage
id='post_info.system'
defaultMessage='System'
/>
}
overwriteName={intl.formatMessage({
id: 'post_info.system',
defaultMessage: 'System',
})}
userId={post.user_id}
disablePopover={true}
channelId={post.channel_id}

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

@@ -230,7 +230,6 @@ exports[`components/post_edit_history/edited_post_item should match snapshot whe
>
<Connect(UserProfile)
disablePopover={true}
hasMention={true}
userId="user_id"
/>
</div>

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

@@ -145,7 +145,6 @@ const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, theme, act
<div className={'edit-post-history__header__username'}>
<UserProfileComponent
userId={post.user_id}
hasMention={true}
disablePopover={true}
overwriteName={overwriteName}
/>

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

@@ -24,7 +24,6 @@ describe('components/PostProfilePicture', () => {
enablePostIconOverride: true,
compactDisplay: true,
hasImageProxy: true,
isBusy: true,
post,
user,
isBot: Boolean(user.is_bot),

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

@@ -18,7 +18,6 @@ type Props = {
compactDisplay?: boolean;
enablePostIconOverride: boolean;
hasImageProxy: boolean;
isBusy?: boolean;
post: Post;
status?: string;
user: UserProfile;
@@ -81,7 +80,6 @@ export default class PostProfilePicture extends React.PureComponent<Props> {
const {
availabilityStatusOnPosts,
compactDisplay,
isBusy,
post,
user,
isBot,
@@ -107,7 +105,6 @@ export default class PostProfilePicture extends React.PureComponent<Props> {
return (
<ProfilePicture
hasMention={hasMention}
isBusy={isBusy}
size='md'
src={src}
profileSrc={profileSrc}

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

@@ -225,7 +225,6 @@ function createDMIntroMessage(channel: Channel, centeredIntro: string, teammate?
<UserProfile
userId={teammate?.id}
disablePopover={false}
hasMention={true}
/>
</div>
<p className='channel-intro-text'>

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

@@ -16,7 +16,6 @@ exports[`components/post_view/CommentedOn should match snapshot 1`] = `
>
<Memo(Connect(UserProfile))
disablePopover={false}
hasMention={true}
userId=""
/>
</a>,
@@ -49,7 +48,6 @@ exports[`components/post_view/CommentedOn should match snapshot 2`] = `
>
<Memo(Connect(UserProfile))
disablePopover={false}
hasMention={true}
userId=""
/>
</a>,
@@ -82,7 +80,6 @@ exports[`components/post_view/CommentedOn should match snapshot 3`] = `
>
<Memo(Connect(UserProfile))
disablePopover={false}
hasMention={true}
userId=""
/>
</a>,
@@ -117,7 +114,6 @@ exports[`components/post_view/CommentedOn should match snapshots for post with p
>
<Memo(Connect(UserProfile))
disablePopover={false}
hasMention={true}
userId=""
/>
</a>,
@@ -150,7 +146,6 @@ exports[`components/post_view/CommentedOn should match snapshots for post with p
>
<Memo(Connect(UserProfile))
disablePopover={false}
hasMention={true}
userId=""
/>
</a>,
@@ -183,7 +178,6 @@ exports[`components/post_view/CommentedOn should match snapshots for post with p
>
<Memo(Connect(UserProfile))
disablePopover={false}
hasMention={true}
userId=""
/>
</a>,
@@ -216,7 +210,6 @@ exports[`components/post_view/CommentedOn should match snapshots for post with p
>
<Memo(Connect(UserProfile))
disablePopover={false}
hasMention={true}
userId=""
/>
</a>,

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

@@ -44,7 +44,6 @@ function CommentedOn({post, parentPostUser, onCommentClick}: Props) {
const parentUserProfile = (
<UserProfile
userId={parentPostUserId}
hasMention={true}
disablePopover={false}
/>
);

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

@@ -34,7 +34,6 @@ exports[`PostMessagePreview direct and group messages should render preview for
>
<Connect(UserProfile)
disablePopover={true}
hasMention={true}
overwriteName=""
userId="user_1"
/>
@@ -121,7 +120,6 @@ exports[`PostMessagePreview direct and group messages should render preview for
>
<Connect(UserProfile)
disablePopover={true}
hasMention={true}
overwriteName=""
userId="user_1"
/>
@@ -208,7 +206,6 @@ exports[`PostMessagePreview nested previews should render file preview 1`] = `
>
<Connect(UserProfile)
disablePopover={true}
hasMention={true}
overwriteName=""
userId="user_1"
/>
@@ -315,7 +312,6 @@ exports[`PostMessagePreview nested previews should render opengraph preview 1`]
>
<Connect(UserProfile)
disablePopover={true}
hasMention={true}
overwriteName=""
userId="user_1"
/>
@@ -430,7 +426,6 @@ exports[`PostMessagePreview should not render bot icon 1`] = `
>
<Connect(UserProfile)
disablePopover={true}
hasMention={true}
overwriteName=""
userId="user_1"
/>
@@ -522,7 +517,6 @@ exports[`PostMessagePreview should render bot icon 1`] = `
>
<Connect(UserProfile)
disablePopover={true}
hasMention={true}
overwriteName=""
userId="user_1"
/>
@@ -614,7 +608,6 @@ exports[`PostMessagePreview should render correctly 1`] = `
>
<Connect(UserProfile)
disablePopover={true}
hasMention={true}
overwriteName=""
userId="user_1"
/>
@@ -703,7 +696,6 @@ exports[`PostMessagePreview show render without preview when preview posts becom
>
<Connect(UserProfile)
disablePopover={true}
hasMention={true}
overwriteName=""
userId="user_1"
/>

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

@@ -174,7 +174,6 @@ const PostMessagePreview = (props: Props) => {
<div className={classNames('col col__name', 'permalink--username')}>
<UserProfileComponent
userId={user?.id ?? ''}
hasMention={true}
disablePopover={true}
overwriteName={previewPost.props?.override_username || ''}
/>

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

@@ -44,11 +44,9 @@ exports[`components/ProfilePicture should match snapshot, profile and src, defau
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hasMention={false}
hide={[Function]}
isBusy={true}
src="http://example.com/image.png"
userId="uid"
/>

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

@@ -13,7 +13,6 @@ describe('components/ProfilePicture', () => {
const baseProps: Props = {
src: 'http://example.com/image.png',
status: 'away',
isBusy: true,
};
test('should match snapshot, no user specified, default props', () => {
@@ -43,7 +42,6 @@ describe('components/ProfilePicture', () => {
const props: Props = {
...baseProps,
size: 'xl',
hasMention: true,
};
const wrapper = shallow(
<ProfilePicture {...props}/>,
@@ -69,7 +67,6 @@ describe('components/ProfilePicture', () => {
...baseProps,
username: 'username',
size: 'xs',
hasMention: true,
};
const wrapper = shallow(
<ProfilePicture {...props}/>,

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

@@ -20,8 +20,6 @@ interface MMOverlayTrigger extends BaseOverlayTrigger {
}
type Props = {
hasMention?: boolean;
isBusy?: boolean;
isEmoji?: boolean;
profileSrc?: string;
size?: ComponentProps<typeof Avatar>['size'];
@@ -80,10 +78,8 @@ export default class ProfilePicture extends React.PureComponent<Props> {
className='user-profile-popover'
userId={this.props.userId}
src={profileSrc}
isBusy={this.props.isBusy}
hide={this.hideProfilePopover}
channelId={this.props.channelId}
hasMention={this.props.hasMention}
overwriteIcon={this.props.overwriteIcon}
overwriteName={this.props.overwriteName}
fromWebhook={this.props.fromWebhook}

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@@ -0,0 +1,321 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import type {ComponentProps} from 'react';
import React from 'react';
import type {UserProfile} from '@mattermost/types/users';
import {CustomStatusDuration} from '@mattermost/types/users';
import type {DeepPartial} from '@mattermost/types/utilities';
import {Client4} from 'mattermost-redux/client';
import {General, Permissions} from 'mattermost-redux/constants';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import ProfilePopover from '.';
jest.mock('@mattermost/client', () => ({
...jest.requireActual('@mattermost/client'),
Client4: class MockClient4 extends jest.requireActual('@mattermost/client').Client4 {
getCallsChannelState = jest.fn();
},
}));
type Props = ComponentProps<typeof ProfilePopover>;
function renderWithPluginReducers(
c: Parameters<typeof renderWithContext>[0],
s: Parameters<typeof renderWithContext>[1],
o?: Parameters<typeof renderWithContext>[2],
): ReturnType<typeof renderWithContext> {
const options = o || {};
options.pluginReducers = ['plugins-com.mattermost.calls'];
return renderWithContext(c, s, options);
}
function getBasePropsAndState(): [Props, DeepPartial<GlobalState>] {
const user = TestHelper.getUserMock({
id: 'user1',
first_name: 'user',
props: {
customStatus: JSON.stringify({
emoji: 'calendar',
text: 'In a meeting',
duration: CustomStatusDuration.DONT_CLEAR,
}),
},
});
const currentUser = TestHelper.getUserMock({id: 'currentUser', roles: 'role'});
const currentTeam = TestHelper.getTeamMock({id: 'currentTeam'});
const channel = TestHelper.getChannelMock({id: 'channelId', team_id: currentTeam.id, type: General.OPEN_CHANNEL});
const state: DeepPartial<GlobalState> = {
entities: {
users: {
profiles: {
[user.id]: user,
[currentUser.id]: currentUser,
},
statuses: {
user1: 'offline',
},
currentUserId: currentUser.id,
lastActivity: {
user1: 1,
},
},
teams: {
teams: {
[currentTeam.id]: currentTeam,
},
currentTeamId: currentTeam.id,
membersInTeam: {
[currentTeam.id]: {
[user.id]: {
delete_at: 0,
},
},
},
},
channels: {
channels: {
[channel.id]: channel,
},
myMembers: {
[channel.id]: {},
},
},
general: {
config: {
EnableCustomUserStatuses: 'true',
EnableLastActiveTime: 'true',
},
},
preferences: {
myPreferences: {},
},
groups: {
groups: {},
myGroups: [],
},
emojis: {
customEmoji: {},
},
posts: {
posts: {},
},
roles: {
roles: {
role: {
permissions: [Permissions.MANAGE_PUBLIC_CHANNEL_MEMBERS],
},
},
},
},
views: {
rhs: {
isSidebarOpen: false,
},
modals: {
modalState: {},
},
browser: {
windowSize: '',
},
},
plugins: {
components: {
CallButton: [{}],
},
plugins: {
'com.mattermost.calls': {
version: '0.4.2',
},
},
},
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
'plugins-com.mattermost.calls': {
profiles: {},
},
};
const props: Props = {
src: 'src',
userId: user.id,
hide: jest.fn(),
channelId: 'channelId',
};
return [props, state];
}
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';
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(await screen.findByLabelText('shared user indicator')).toBeInTheDocument();
});
test('should have bot description', async () => {
const [props, initialState] = getBasePropsAndState();
initialState.entities!.users!.profiles!.user1!.is_bot = true;
initialState.entities!.users!.profiles!.user1!.bot_description = 'bot description';
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(await screen.findByText('bot description')).toBeInTheDocument();
});
test('should show add-to-channel option if in a team', async () => {
const [props, initialState] = getBasePropsAndState();
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(await screen.findByLabelText('Add to a Channel dialog')).toBeInTheDocument();
});
test('should hide add-to-channel option if not on team', async () => {
const [props, initialState] = getBasePropsAndState();
initialState.entities!.teams!.membersInTeam!.currentTeam = {};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
// Use find to wait for the first re-render because of the calls fetch
await screen.findByText('user');
expect(await screen.queryByLabelText('Add to a Channel dialog')).not.toBeInTheDocument();
});
test('should match props passed into PopoverUserAttributes Pluggable component', async () => {
const [props, initialState] = getBasePropsAndState();
const mockPluginComponent = ({
hide,
status,
user,
}: {
hide: Props['hide'];
status?: string;
user: UserProfile;
}) => {
hide?.();
return (<span>{`${status} ${user.id}`}</span>);
};
initialState.plugins!.components!.PopoverUserAttributes = [{component: mockPluginComponent as any}];
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(props.hide).toHaveBeenCalled();
expect(await screen.findByText('offline user1')).toBeInTheDocument();
});
test('should match props passed into PopoverUserActions Pluggable component', async () => {
const [props, initialState] = getBasePropsAndState();
const mockPluginComponent = ({
hide,
status,
user,
}: {
hide: Props['hide'];
status?: string;
user: UserProfile;
}) => {
hide?.();
return (<span>{`${status} ${user.id}`}</span>);
};
initialState.plugins!.components!.PopoverUserActions = [{component: mockPluginComponent as any}];
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(props.hide).toHaveBeenCalled();
expect(await screen.findByText('offline user1')).toBeInTheDocument();
});
test('should show custom status', async () => {
const [props, initialState] = getBasePropsAndState();
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(await screen.findByText('In a meeting')).toBeInTheDocument();
});
test('should show to set a status for the current user', async () => {
const [props, initialState] = getBasePropsAndState();
props.userId = initialState.entities!.users!.currentUserId!;
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(await screen.findByText('Set a status')).toBeInTheDocument();
});
test('should not show custom status expired', async () => {
const [props, initialState] = getBasePropsAndState();
const customStatus = JSON.stringify({
emoji: 'calendar',
text: 'In a meeting',
duration: CustomStatusDuration.TODAY,
expires_at: '2021-05-03T23:59:59.000Z',
});
initialState.entities!.users!.profiles!.user1!.props!.customStatus = customStatus;
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
// Use find to wait for the first re-render because of the calls fetch
await screen.findByText('user');
expect(await screen.queryByText('In a meeting')).not.toBeInTheDocument();
});
test('should show last active display', async () => {
const [props, initialState] = getBasePropsAndState();
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(await screen.findByText('January 01, 1970')).toBeInTheDocument();
});
test('should not show last active display if disabled', async () => {
const [props, initialState] = getBasePropsAndState();
initialState.entities!.general!.config!.EnableLastActiveTime = 'false';
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
// Use find to wait for the first re-render because of the calls fetch
await screen.findByText('user');
expect(screen.queryByText('January 01, 1970')).not.toBeInTheDocument();
});
test('should show start a call button', async () => {
const [props, initialState] = getBasePropsAndState();
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(await screen.findByLabelText('Start Call')).toBeInTheDocument();
});
test('should not show start a call button when calls are disabled', async () => {
const [props, initialState] = getBasePropsAndState();
initialState.plugins!.plugins = {};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(screen.queryByLabelText('Start Call')).not.toBeInTheDocument();
});
test('should disable start call button when user is in another call', async () => {
const [props, initialState] = getBasePropsAndState();
(initialState as any)['plugins-com.mattermost.calls'].profiles = {fakeChannel: {currentUser: {id: 'currentUser'}}};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
const button = (await screen.findByLabelText('Start Call')).closest('button');
expect(button?.getAttribute('aria-disabled')).toBe('true');
});
test('should not show the start call button when isCallsDefaultEnabledOnAllChannels, isCallsCanBeDisabledOnSpecificChannels is false and callsChannelState.enabled is false', async () => {
(Client4.getCallsChannelState as jest.Mock).mockImplementationOnce(async () => ({enabled: false}));
const [props, initialState] = getBasePropsAndState();
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(await screen.findByLabelText('Start Call')).not.toBeInTheDocument();
});
});

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

@@ -1,132 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {
canManageAnyChannelMembersInCurrentTeam,
getCurrentChannelId,
getChannelByName,
getChannelMember,
} from 'mattermost-redux/selectors/entities/channels';
import {getCallsConfig, getProfilesInCalls} from 'mattermost-redux/selectors/entities/common';
import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
import {
getCurrentTeam,
getCurrentRelativeTeamUrl,
getTeamMember,
} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import {displayLastActiveLabel, getCurrentUserId, getLastActiveTimestampUnits, getLastActivityForUserId, getStatusForUserId, getUser} from 'mattermost-redux/selectors/entities/users';
import {openDirectChannelToUserId} from 'actions/channel_actions';
import {closeModal, openModal} from 'actions/views/modals';
import {getMembershipForEntities} from 'actions/views/profile_popover';
import {isCallsEnabled} from 'selectors/calls';
import {getRhsState, getSelectedPost} from 'selectors/rhs';
import {getIsMobileView} from 'selectors/views/browser';
import {makeGetCustomStatus, isCustomStatusEnabled, isCustomStatusExpired} from 'selectors/views/custom_status';
import {isAnyModalOpen} from 'selectors/views/modals';
import {getDirectChannelName} from 'utils/utils';
import type {GlobalState} from 'types/store';
import ProfilePopover from './profile_popover';
type OwnProps = {
userId: string;
channelId?: string;
}
function getDefaultChannelId(state: GlobalState) {
const selectedPost = getSelectedPost(state);
return selectedPost.exists ? selectedPost.channel_id : getCurrentChannelId(state);
}
export function checkUserInCall(state: GlobalState, userId: string) {
for (const profilesMap of Object.values(getProfilesInCalls(state))) {
for (const profile of Object.values(profilesMap || {})) {
if (profile?.id === userId) {
return true;
}
}
}
return false;
}
function makeMapStateToProps() {
const getCustomStatus = makeGetCustomStatus();
return (state: GlobalState, {userId, channelId = getDefaultChannelId(state)}: OwnProps) => {
const team = getCurrentTeam(state);
const teamMember = getTeamMember(state, team.id, userId);
const isTeamAdmin = Boolean(teamMember && teamMember.scheme_admin);
const channelMember = getChannelMember(state, channelId, userId);
let isChannelAdmin = false;
if (getRhsState(state) !== 'search' && channelMember != null && channelMember.scheme_admin) {
isChannelAdmin = true;
}
const customStatus = getCustomStatus(state, userId);
const status = getStatusForUserId(state, userId);
const user = getUser(state, userId);
const lastActivityTimestamp = getLastActivityForUserId(state, userId);
const timestampUnits = getLastActiveTimestampUnits(state, userId);
const enableLastActiveTime = displayLastActiveLabel(state, userId);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const callsEnabled = isCallsEnabled(state);
const currentUserId = getCurrentUserId(state);
const callsConfig = callsEnabled ? getCallsConfig(state) : undefined;
return {
currentTeamId: team.id,
currentUserId,
isTeamAdmin,
isChannelAdmin,
isInCurrentTeam: Boolean(teamMember) && teamMember?.delete_at === 0,
canManageAnyChannelMembersInCurrentTeam: canManageAnyChannelMembersInCurrentTeam(state),
status,
teamUrl: getCurrentRelativeTeamUrl(state),
user,
modals: state.views.modals,
customStatus,
isCustomStatusEnabled: isCustomStatusEnabled(state),
isCustomStatusExpired: isCustomStatusExpired(state, customStatus),
channelId,
currentUserTimezone: getCurrentTimezone(state),
lastActivityTimestamp,
enableLastActiveTime,
timestampUnits,
isMobileView: getIsMobileView(state),
isCallsEnabled: callsEnabled,
isUserInCall: callsEnabled ? checkUserInCall(state, userId) : undefined,
isCurrentUserInCall: callsEnabled ? checkUserInCall(state, currentUserId) : undefined,
isCallsDefaultEnabledOnAllChannels: callsConfig?.DefaultEnabled,
isCallsCanBeDisabledOnSpecificChannels: callsConfig?.AllowEnableCalls,
dMChannel: getChannelByName(state, getDirectChannelName(currentUserId, userId)),
teammateNameDisplay: getTeammateNameDisplaySetting(state),
isAnyModalOpen: isAnyModalOpen(state),
};
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
closeModal,
openDirectChannelToUserId,
openModal,
getMembershipForEntities,
}, dispatch),
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(ProfilePopover);

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

@@ -0,0 +1,337 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {getCurrentChannelId, getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentRelativeTeamUrl, getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import {getStatusForUserId, getUser} from 'mattermost-redux/selectors/entities/users';
import type {DispatchFunc} from 'mattermost-redux/types/actions';
import {displayUsername} from 'mattermost-redux/utils/user_utils';
import {openDirectChannelToUserId} from 'actions/channel_actions';
import * as GlobalActions from 'actions/global_actions';
import {closeModal} from 'actions/views/modals';
import {getMembershipForEntities} from 'actions/views/profile_popover';
import {getSelectedPost} from 'selectors/rhs';
import {getIsMobileView} from 'selectors/views/browser';
import {isAnyModalOpen as getIsAnyModalOpen} from 'selectors/views/modals';
import useDidUpdate from 'components/common/hooks/useDidUpdate';
import Popover from 'components/widgets/popover';
import Pluggable from 'plugins/pluggable';
import {getHistory} from 'utils/browser_history';
import Constants, {A11yClassNames, A11yCustomEventTypes, UserStatuses} from 'utils/constants';
import type {A11yFocusEventDetail} from 'utils/constants';
import * as Keyboard from 'utils/keyboard';
import {shouldFocusMainTextbox} from 'utils/post_utils';
import * as Utils from 'utils/utils';
import type {GlobalState} from 'types/store';
import ProfilePopoverActions from './profile_popover_actions';
import ProfilePopoverAvatar from './profile_popover_avatar';
import ProfilePopoverCustomStatus from './profile_popover_custom_status';
import ProfilePopoverEdit from './profile_popover_edit';
import ProfilePopoverEmail from './profile_popover_email';
import ProfilePopoverLastActive from './profile_popover_last_active';
import ProfilePopoverName from './profile_popover_name';
import ProfilePopoverOverrideDisclaimer from './profile_popover_override_disclaimer';
import ProfilePopoverTitle from './profile_popover_title';
import ProfileTimezone from './profile_timezone';
import './profile_popover.scss';
export interface ProfilePopoverProps extends Omit<React.ComponentProps<typeof Popover>, 'id'> {
/**
* Source URL from the image to display in the popover
*/
src: string;
/**
* Source URL from the image that should override default image
*/
overwriteIcon?: string;
/**
* Set to true of the popover was opened from a webhook post
*/
fromWebhook?: boolean;
userId: string;
channelId?: string;
hideStatus?: boolean;
/**
* Function to call to hide the popover
*/
hide?: () => void;
/**
* Function to call to return focus to the previously focused element when the popover closes.
* If not provided, the popover will automatically determine the previously focused element
* and focus that on close. However, if the previously focused element is not correctly detected
* by the popover, or the previously focused element will disappear after the popover opens,
* it is necessary to provide this function to focus the correct element.
*/
returnFocus?: () => void;
/**
* The overwritten username that should be shown at the top of the popover
*/
overwriteName?: string;
}
function getDefaultChannelId(state: GlobalState) {
const selectedPost = getSelectedPost(state);
return selectedPost.exists ? selectedPost.channel_id : getCurrentChannelId(state);
}
/**
* The profile popover, or hovercard, that appears with user information when clicking
* on the username or profile picture of a user.
*/
const ProfilePopover = ({
returnFocus: returnFocusProp,
userId,
channelId: channelIdProp,
hide,
overwriteIcon,
overwriteName,
src,
hideStatus,
fromWebhook,
...restProps
}: ProfilePopoverProps) => {
const {formatMessage} = useIntl();
const dispatch = useDispatch<DispatchFunc>();
const user = useSelector((state: GlobalState) => getUser(state, userId));
const currentTeamId = useSelector((state: GlobalState) => getCurrentTeamId(state));
const channelId = useSelector((state: GlobalState) => (channelIdProp || getDefaultChannelId(state)));
const isAnyModalOpen = useSelector(getIsAnyModalOpen);
const isMobileView = useSelector(getIsMobileView);
const teamUrl = useSelector(getCurrentRelativeTeamUrl);
const modals = useSelector((state: GlobalState) => state.views.modals);
const teammateNameDisplay = useSelector(getTeammateNameDisplaySetting);
const status = useSelector((state: GlobalState) => getStatusForUserId(state, userId) || UserStatuses.OFFLINE);
const currentUserTimezone = useSelector(getCurrentTimezone);
const currentUserId = useSelector(getCurrentUserId);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const [loadingDMChannel, setLoadingDMChannel] = useState<string>();
const returnFocus = useMemo(() => {
if (returnFocusProp) {
return returnFocusProp;
}
const previouslyFocused = document.activeElement;
return () => {
document.dispatchEvent(new CustomEvent<A11yFocusEventDetail>(
A11yCustomEventTypes.FOCUS, {
detail: {
target: previouslyFocused as HTMLElement,
keyboardOnly: true,
},
},
));
};
}, []);
const handleCloseModals = useCallback(() => {
for (const modal in modals?.modalState) {
if (!Object.prototype.hasOwnProperty.call(modals, modal)) {
continue;
}
if (modals?.modalState[modal].open) {
dispatch(closeModal(modal));
}
}
}, [modals]);
const handleShowDirectChannel = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!user) {
return;
}
if (loadingDMChannel !== undefined) {
return;
}
setLoadingDMChannel(user.id);
handleCloseModals();
const result = await dispatch(openDirectChannelToUserId(user.id));
if (!result.error) {
if (isMobileView) {
GlobalActions.emitCloseRightHandSide();
}
setLoadingDMChannel(undefined);
hide?.();
getHistory().push(`${teamUrl}/messages/@${user.username}`);
}
}, [user, loadingDMChannel, handleCloseModals, isMobileView, hide, teamUrl]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (shouldFocusMainTextbox(e, document.activeElement)) {
hide?.();
} else if (Keyboard.isKeyPressed(e, Constants.KeyCodes.ESCAPE)) {
returnFocus();
}
}, [hide, returnFocus]);
useEffect(() => {
if (currentTeamId && userId) {
dispatch(getMembershipForEntities(
currentTeamId,
userId,
channelId,
));
}
// Focus the close button when the popover first opens, to bring the focus into the popover.
document.dispatchEvent(new CustomEvent<A11yFocusEventDetail>(
A11yCustomEventTypes.FOCUS, {
detail: {
target: closeButtonRef.current,
keyboardOnly: true,
},
},
));
}, []);
useDidUpdate(() => {
hide?.();
}, [isAnyModalOpen]);
if (!user) {
return null;
}
const urlSrc = overwriteIcon || src;
const haveOverrideProp = Boolean(overwriteIcon || overwriteName);
const fullname = overwriteName || Utils.getFullName(user);
const displayName = displayUsername(user, teammateNameDisplay);
const tabCatcher = (
<span
tabIndex={0}
onFocus={(e) => (e.relatedTarget as HTMLElement).focus()}
/>
);
return (
<Popover
{...restProps}
id='user-profile-popover'
>
{tabCatcher}
<div
role='dialog'
aria-label={formatMessage(
{
id: 'profile_popover.profileLabel',
defaultMessage: 'Profile for {name}',
},
{name: displayName},
)}
onKeyDown={handleKeyDown}
className={A11yClassNames.POPUP}
aria-modal={true}
>
<ProfilePopoverTitle
channelId={channelId}
closeButtonRef={closeButtonRef}
isBot={user.is_bot}
returnFocus={returnFocus}
roles={user.roles}
userId={user.id}
username={user.username}
hide={hide}
/>
<div className='user-profile-popover__content'>
<ProfilePopoverAvatar
hideStatus={hideStatus}
urlSrc={urlSrc}
username={user.username}
status={status}
/>
<ProfilePopoverLastActive userId={user.id}/>
<ProfilePopoverName
user={user}
haveOverrideProp={haveOverrideProp}
fullname={fullname}
/>
<hr className='divider divider--expanded'/>
<ProfilePopoverEmail
email={user.email}
haveOverrideProp={haveOverrideProp}
isBot={user.is_bot}
/>
<Pluggable
pluggableName='PopoverUserAttributes'
user={user}
hide={hide}
status={hideStatus ? null : status}
fromWebhook={fromWebhook}
/>
<ProfileTimezone
currentUserTimezone={currentUserTimezone}
profileUserTimezone={user.timezone}
haveOverrideProp={haveOverrideProp}
/>
<ProfilePopoverCustomStatus
currentUserId={currentUserId}
currentUserTimezone={currentUserTimezone}
haveOverrideProp={haveOverrideProp}
hideStatus={hideStatus}
user={user}
returnFocus={returnFocus}
hide={hide}
/>
<ProfilePopoverEdit
currentUserId={currentUserId}
handleCloseModals={handleCloseModals}
handleShowDirectChannel={handleShowDirectChannel}
haveOverrideProp={haveOverrideProp}
returnFocus={returnFocus}
userId={user.id}
hide={hide}
/>
<ProfilePopoverOverrideDisclaimer
haveOverrideProp={haveOverrideProp}
username={user.username}
/>
<ProfilePopoverActions
channelId={channelId}
currentUserId={currentUserId}
fullname={fullname}
handleCloseModals={handleCloseModals}
handleShowDirectChannel={handleShowDirectChannel}
haveOverrideProp={haveOverrideProp}
returnFocus={returnFocus}
user={user}
hide={hide}
/>
<Pluggable
pluggableName='PopoverUserActions'
user={user}
hide={hide}
status={hideStatus ? null : status}
/>
</div>
</div>
{tabCatcher}
</Popover>
);
};
export default React.memo(ProfilePopover);

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

@@ -1,339 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Provider} from 'react-redux';
import {CustomStatusDuration} from '@mattermost/types/users';
import {General} from 'mattermost-redux/constants';
import {checkUserInCall} from 'components/profile_popover';
import ProfilePopover from 'components/profile_popover/profile_popover';
import Pluggable from 'plugins/pluggable';
import {mountWithIntl, shallowWithIntl} from 'tests/helpers/intl-test-helper';
import {mockStore} from 'tests/test_store';
import {TestHelper} from 'utils/test_helper';
describe('components/ProfilePopover', () => {
const baseProps = {
userId: '0',
user: TestHelper.getUserMock({
username: 'some_username',
}),
hide: jest.fn(),
src: 'src',
currentUserId: '',
currentTeamId: 'team_id',
isChannelAdmin: false,
isTeamAdmin: false,
isInCurrentTeam: true,
teamUrl: '',
canManageAnyChannelMembersInCurrentTeam: true,
isCustomStatusEnabled: true,
isCustomStatusExpired: false,
isMobileView: false,
actions: {
getMembershipForEntities: jest.fn(),
openDirectChannelToUserId: jest.fn(),
openModal: jest.fn(),
closeModal: jest.fn(),
loadBot: jest.fn(),
},
lastActivityTimestamp: 1632146562846,
enableLastActiveTime: true,
timestampUnits: [
'now',
'minute',
'hour',
],
isCallsEnabled: true,
isCallsDefaultEnabledOnAllChannels: true,
teammateNameDisplay: General.TEAMMATE_NAME_DISPLAY.SHOW_USERNAME,
isAnyModalOpen: false,
};
const initialState = {
entities: {
teams: {},
channels: {
channels: {},
myMembers: {},
},
general: {
config: {},
},
users: {
currentUserId: '',
profiles: {
user1: {
id: 'user1',
roles: '',
},
},
},
preferences: {
myPreferences: {},
},
groups: {
groups: {},
myGroups: [],
},
emojis: {
customEmoji: {},
},
},
plugins: {
components: {
CallButton: [],
},
},
views: {
rhs: {
isSidebarOpen: false,
},
},
};
test('should match snapshot', () => {
const props = {...baseProps};
const wrapper = shallowWithIntl(
<ProfilePopover {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot for shared user', () => {
const props = {
...baseProps,
user: TestHelper.getUserMock({
username: 'shared_user',
first_name: 'shared',
remote_id: 'fakeuser',
}),
};
const wrapper = shallowWithIntl(
<ProfilePopover {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should have bot description', () => {
const props = {
...baseProps,
user: TestHelper.getUserMock({
is_bot: true,
bot_description: 'bot description',
}),
};
const wrapper = shallowWithIntl(
<ProfilePopover {...props}/>,
);
expect(wrapper.containsMatchingElement(
<div
key='bot-description'
>
{'bot description'}
</div>,
)).toEqual(true);
});
test('should hide add-to-channel option if not on team', () => {
const props = {...baseProps};
props.isInCurrentTeam = false;
const wrapper = shallowWithIntl(
<ProfilePopover {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match props passed into Pluggable component', () => {
const hide = jest.fn();
const status = 'online';
const props = {...baseProps, hide, status};
const wrapper = shallowWithIntl(
<ProfilePopover {...props}/>,
);
const pluggableProps = {
hide,
status,
user: props.user,
};
expect(wrapper.find(Pluggable).first().props()).toEqual({...pluggableProps, pluggableName: 'PopoverUserAttributes'});
expect(wrapper.find(Pluggable).last().props()).toEqual({...pluggableProps, pluggableName: 'PopoverUserActions'});
});
test('should match snapshot with custom status', () => {
const customStatus = {
emoji: 'calendar',
text: 'In a meeting',
duration: CustomStatusDuration.TODAY,
expires_at: '2021-05-03T23:59:59.000Z',
};
const props = {
...baseProps,
customStatus,
};
const wrapper = shallowWithIntl(
<ProfilePopover {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with custom status not set but can set', () => {
const props = {
...baseProps,
user: {
...baseProps.user,
id: '',
},
};
const wrapper = shallowWithIntl(
<ProfilePopover {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with custom status expired', () => {
const customStatus = {
emoji: 'calendar',
text: 'In a meeting',
duration: CustomStatusDuration.TODAY,
expires_at: '2021-05-03T23:59:59.000Z',
};
const props = {
...baseProps,
isCustomStatusExpired: true,
customStatus,
};
const wrapper = shallowWithIntl(
<ProfilePopover {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with last active display', () => {
const props = {
...baseProps,
status: 'offline',
};
const wrapper = shallowWithIntl(
<ProfilePopover {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with no last active display because it is disabled', () => {
const props = {
...baseProps,
enableLastActiveTime: false,
};
const wrapper = shallowWithIntl(
<ProfilePopover {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot when calls are disabled', () => {
const props = {
...baseProps,
isCallsEnabled: false,
};
const wrapper = shallowWithIntl(
<ProfilePopover {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should disable start call button when user is in another call', () => {
const props = {
...baseProps,
isUserInCall: true,
};
const wrapper = shallowWithIntl(
<ProfilePopover {...props}/>,
);
expect(wrapper.find('#startCallButton').hasClass('icon-btn-disabled')).toBe(true);
expect(wrapper).toMatchSnapshot();
});
test('should show the start call button when isCallsDefaultEnabledOnAllChannels, isCallsCanBeDisabledOnSpecificChannels is false and callsChannelState.enabled is true', () => {
const mock = mockStore(initialState);
const props = {
...baseProps,
isCallsDefaultEnabledOnAllChannels: false,
isCallsCanBeDisabledOnSpecificChannels: false,
};
const wrapper = mountWithIntl(
<Provider store={mock.store}>
<ProfilePopover {...props}/>
</Provider>,
);
expect(wrapper.find('ProfilePopoverCallButton').exists()).toBe(true);
expect(wrapper).toMatchSnapshot();
});
});
describe('checkUserInCall', () => {
test('missing state', () => {
expect(checkUserInCall({
'plugins-com.mattermost.calls': {},
} as any, 'userA')).toBe(false);
});
test('call state missing', () => {
expect(checkUserInCall({
'plugins-com.mattermost.calls': {
profiles: {
channelID: null,
},
},
} as any, 'userA')).toBe(false);
});
test('user not in call', () => {
expect(checkUserInCall({
'plugins-com.mattermost.calls': {
profiles: {
channelID: {
sessionB: {
id: 'userB',
},
},
},
},
} as any, 'userA')).toBe(false);
});
test('user in call', () => {
expect(checkUserInCall({
'plugins-com.mattermost.calls': {
profiles: {
channelID: {
sessionB: {
id: 'userB',
},
sessionA: {
id: 'userA',
},
},
},
},
} as any, 'userA')).toBe(true);
});
});

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

@@ -1,913 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {FormattedMessage, injectIntl} from 'react-intl';
import type {IntlShape} from 'react-intl';
import {AccountOutlineIcon, AccountPlusOutlineIcon, CloseIcon, EmoticonHappyOutlineIcon, PhoneInTalkIcon, SendIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import type {UserCustomStatus, UserProfile} from '@mattermost/types/users';
import {CustomStatusDuration} from '@mattermost/types/users';
import {Client4} from 'mattermost-redux/client';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {displayUsername, isGuest, isSystemAdmin} from 'mattermost-redux/utils/user_utils';
import * as GlobalActions from 'actions/global_actions';
import AddUserToChannelModal from 'components/add_user_to_channel_modal';
import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
import CustomStatusModal from 'components/custom_status/custom_status_modal';
import CustomStatusText from 'components/custom_status/custom_status_text';
import ExpiryTime from 'components/custom_status/expiry_time';
import OverlayTrigger from 'components/overlay_trigger';
import ProfilePopoverCallButton from 'components/profile_popover_call_button';
import SharedUserIndicator from 'components/shared_user_indicator';
import StatusIcon from 'components/status_icon';
import Timestamp from 'components/timestamp';
import ToggleModalButton from 'components/toggle_modal_button';
import Tooltip from 'components/tooltip';
import UserSettingsModal from 'components/user_settings/modal';
import Popover from 'components/widgets/popover';
import BotTag from 'components/widgets/tag/bot_tag';
import GuestTag from 'components/widgets/tag/guest_tag';
import Tag from 'components/widgets/tag/tag';
import Avatar from 'components/widgets/users/avatar';
import Pluggable from 'plugins/pluggable';
import {getHistory} from 'utils/browser_history';
import Constants, {A11yClassNames, A11yCustomEventTypes, ModalIdentifiers, UserStatuses} from 'utils/constants';
import type {A11yFocusEventDetail} from 'utils/constants';
import {t} from 'utils/i18n';
import * as Keyboard from 'utils/keyboard';
import {shouldFocusMainTextbox} from 'utils/post_utils';
import * as Utils from 'utils/utils';
import type {ModalData} from 'types/actions';
import {ProfileTimezone} from './profile_localtime';
import './profile_popover.scss';
export interface ProfilePopoverProps extends Omit<React.ComponentProps<typeof Popover>, 'id'> {
/**
* Source URL from the image to display in the popover
*/
src: string;
/**
* Source URL from the image that should override default image
*/
overwriteIcon?: string;
/**
* Set to true of the popover was opened from a webhook post
*/
fromWebhook?: boolean;
/**
* User the popover is being opened for
*/
user?: UserProfile;
userId: string;
channelId?: string;
/**
* Status for the user, either 'offline', 'away', 'dnd' or 'online'
*/
status?: string;
hideStatus?: boolean;
/**
* Function to call to hide the popover
*/
hide?: () => void;
/**
* Function to call to return focus to the previously focused element when the popover closes.
* If not provided, the popover will automatically determine the previously focused element
* and focus that on close. However, if the previously focused element is not correctly detected
* by the popover, or the previously focused element will disappear after the popover opens,
* it is necessary to provide this function to focus the correct element.
*/
returnFocus?: () => void;
isBusy?: boolean;
isMobileView: boolean;
/**
* Returns state of modals in redux for determing which need to be closed
*/
modals?: {
modalState: {
[modalId: string]: {
open: boolean;
dialogProps: Record<string, any>;
dialogType: React.ComponentType;
};
};
};
currentTeamId: string;
/**
* @internal
*/
currentUserId: string;
customStatus?: UserCustomStatus | null;
isCustomStatusEnabled: boolean;
isCustomStatusExpired: boolean;
currentUserTimezone?: string;
/**
* @internal
*/
hasMention?: boolean;
/**
* @internal
*/
isInCurrentTeam: boolean;
/**
* @internal
*/
teamUrl: string;
/**
* @internal
*/
isTeamAdmin: boolean;
/**
* @internal
*/
isChannelAdmin: boolean;
/**
* @internal
*/
canManageAnyChannelMembersInCurrentTeam: boolean;
/**
* @internal
*/
teammateNameDisplay: string;
/**
* The overwritten username that should be shown at the top of the popover
*/
overwriteName?: React.ReactNode;
/**
* @internal
*/
actions: {
openModal: <P>(modalData: ModalData<P>) => void;
closeModal: (modalId: string) => void;
openDirectChannelToUserId: (userId: string) => Promise<ActionResult>;
getMembershipForEntities: (teamId: string, userId: string, channelId?: string) => void;
};
intl: IntlShape;
lastActivityTimestamp: number;
enableLastActiveTime: boolean;
timestampUnits: string[];
isCallsEnabled: boolean;
isUserInCall?: boolean;
isCurrentUserInCall?: boolean;
isCallsDefaultEnabledOnAllChannels?: boolean;
isCallsCanBeDisabledOnSpecificChannels?: boolean;
dMChannel?: Channel | null;
isAnyModalOpen: boolean;
}
type ProfilePopoverState = {
loadingDMChannel?: string;
callsChannelState?: ChannelCallsState;
callsDMChannelState?: ChannelCallsState;
};
type ChannelCallsState = {
enabled: boolean;
id: string;
};
/**
* The profile popover, or hovercard, that appears with user information when clicking
* on the username or profile picture of a user.
*/
class ProfilePopover extends React.PureComponent<ProfilePopoverProps, ProfilePopoverState> {
closeButtonRef: React.RefObject<HTMLButtonElement>;
returnFocus: () => void;
static getComponentName() {
return 'ProfilePopover';
}
static defaultProps = {
hasMention: false,
status: UserStatuses.OFFLINE,
customStatus: null,
};
constructor(props: ProfilePopoverProps) {
super(props);
this.state = {
loadingDMChannel: undefined,
};
this.closeButtonRef = React.createRef();
if (this.props.returnFocus) {
this.returnFocus = this.props.returnFocus;
} else {
const previouslyFocused = document.activeElement;
this.returnFocus = () => {
document.dispatchEvent(new CustomEvent<A11yFocusEventDetail>(
A11yCustomEventTypes.FOCUS, {
detail: {
target: previouslyFocused as HTMLElement,
keyboardOnly: true,
},
},
));
};
}
}
componentDidMount() {
const {currentTeamId, userId, channelId} = this.props;
if (currentTeamId && userId) {
this.props.actions.getMembershipForEntities(
currentTeamId,
userId,
channelId,
);
}
if (this.props.isCallsEnabled && this.props.dMChannel) {
this.getCallsChannelState(this.props.dMChannel.id).then((data) => {
this.setState({callsDMChannelState: data});
});
}
if (this.props.isCallsEnabled && this.props.channelId) {
this.getCallsChannelState(this.props.channelId).then((data) => {
this.setState({callsChannelState: data});
});
}
// Focus the close button when the popover first opens, to bring the focus into the popover.
document.dispatchEvent(new CustomEvent<A11yFocusEventDetail>(
A11yCustomEventTypes.FOCUS, {
detail: {
target: this.closeButtonRef.current,
keyboardOnly: true,
},
},
));
}
componentDidUpdate(prevProps: ProfilePopoverProps) {
if (this.props.isAnyModalOpen !== prevProps.isAnyModalOpen) {
this.props.hide?.();
}
}
handleShowDirectChannel = (e: React.MouseEvent<HTMLButtonElement>) => {
const {actions} = this.props;
e.preventDefault();
if (!this.props.user) {
return;
}
const user = this.props.user;
if (this.state.loadingDMChannel !== undefined) {
return;
}
this.setState({loadingDMChannel: user.id});
actions.openDirectChannelToUserId(user.id).then((result: ActionResult) => {
if (!result.error) {
if (this.props.isMobileView) {
GlobalActions.emitCloseRightHandSide();
}
this.setState({loadingDMChannel: undefined});
this.props.hide?.();
getHistory().push(`${this.props.teamUrl}/messages/@${user.username}`);
}
});
this.handleCloseModals();
};
handleEditAccountSettings = () => {
if (!this.props.user) {
return;
}
this.props.hide?.();
this.props.actions.openModal({
modalId: ModalIdentifiers.USER_SETTINGS,
dialogType: UserSettingsModal,
dialogProps: {isContentProductSettings: false, onExited: this.returnFocus},
});
this.handleCloseModals();
};
showCustomStatusModal = () => {
this.props.hide?.();
const customStatusInputModalData = {
modalId: ModalIdentifiers.CUSTOM_STATUS,
dialogType: CustomStatusModal,
dialogProps: {onExited: this.returnFocus},
};
this.props.actions.openModal(customStatusInputModalData);
};
handleAddToChannel = () => {
this.props.hide?.();
this.handleCloseModals();
};
handleCloseModals = () => {
const {modals} = this.props;
for (const modal in modals?.modalState) {
if (!Object.prototype.hasOwnProperty.call(modals, modal)) {
continue;
}
if (modals?.modalState[modal].open) {
this.props.actions.closeModal(modal);
}
}
};
handleKeyDown = (e: React.KeyboardEvent) => {
if (shouldFocusMainTextbox(e, document.activeElement)) {
this.props.hide?.();
} else if (Keyboard.isKeyPressed(e, Constants.KeyCodes.ESCAPE)) {
this.returnFocus();
}
};
renderCustomStatus() {
const {
customStatus,
isCustomStatusEnabled,
user,
currentUserId,
hideStatus,
isCustomStatusExpired,
} = this.props;
const customStatusSet = (customStatus?.text || customStatus?.emoji) && !isCustomStatusExpired;
const canSetCustomStatus = user?.id === currentUserId;
const shouldShowCustomStatus =
isCustomStatusEnabled &&
!hideStatus &&
(customStatusSet || canSetCustomStatus);
if (!shouldShowCustomStatus) {
return null;
}
let customStatusContent;
let expiryContent;
if (customStatusSet) {
const customStatusEmoji = (
<CustomStatusEmoji
userID={this.props.user?.id}
showTooltip={false}
emojiStyle={{
marginRight: 4,
marginTop: 1,
}}
/>
);
customStatusContent = (
<div className='d-flex align-items-center'>
{customStatusEmoji}
<CustomStatusText
tooltipDirection='top'
text={customStatus?.text || ''}
className='user-popover__email'
/>
</div>
);
expiryContent = customStatusSet && customStatus?.expires_at && customStatus.duration !== CustomStatusDuration.DONT_CLEAR && (
<ExpiryTime
time={customStatus.expires_at}
timezone={this.props.currentUserTimezone}
className='ml-1'
withinBrackets={true}
/>
);
} else if (canSetCustomStatus) {
customStatusContent = (
<button
className='user-popover__set-custom-status-btn'
onClick={this.showCustomStatusModal}
>
<EmoticonHappyOutlineIcon size={14}/>
<FormattedMessage
id='user_profile.custom_status.set_status'
defaultMessage='Set a status'
/>
</button>
);
}
return {customStatusContent, expiryContent};
}
handleClose = () => {
this.props.hide?.();
this.returnFocus();
};
getCallsChannelState(channelId: string): Promise<ChannelCallsState> {
let data: Promise<ChannelCallsState>;
try {
data = Client4.getCallsChannelState(channelId);
} catch (error) {
return error;
}
return data;
}
render() {
if (!this.props.user) {
return null;
}
const keysToBeRemoved: Array<keyof ProfilePopoverProps> = ['user', 'userId', 'channelId', 'src', 'status', 'hideStatus', 'isBusy',
'hide', 'hasMention', 'currentUserId', 'currentTeamId', 'teamUrl', 'actions', 'isTeamAdmin',
'isChannelAdmin', 'canManageAnyChannelMembersInCurrentTeam', 'intl'];
const popoverProps: React.ComponentProps<typeof Popover> = Utils.deleteKeysFromObject({...this.props},
keysToBeRemoved);
const {formatMessage} = this.props.intl;
const dataContent = [];
const urlSrc = this.props.overwriteIcon ? this.props.overwriteIcon : this.props.src;
dataContent.push(
<div
className='user-popover-image'
key='user-popover-image'
>
<Avatar
id='userAvatar'
size='xxl'
username={this.props.user?.username || ''}
url={urlSrc}
tabIndex={-1}
/>
<StatusIcon
className='status user-popover-status'
status={this.props.hideStatus ? undefined : this.props.status}
button={true}
/>
</div>,
);
if (this.props.enableLastActiveTime && this.props.lastActivityTimestamp && this.props.timestampUnits) {
dataContent.push(
<div
className='user-popover-last-active'
key='user-popover-last-active'
>
<FormattedMessage
id='channel_header.lastOnline'
defaultMessage='Last online {timestamp}'
values={{
timestamp: (
<Timestamp
value={this.props.lastActivityTimestamp}
units={this.props.timestampUnits}
useTime={false}
style={'short'}
/>
),
}}
/>
</div>,
);
}
const fullname = this.props.overwriteName ? this.props.overwriteName : Utils.getFullName(this.props.user);
const haveOverrideProp = this.props.overwriteIcon || this.props.overwriteName;
if (fullname) {
let sharedIcon;
if (this.props.user.remote_id) {
sharedIcon = (
<SharedUserIndicator
className='shared-user-icon'
withTooltip={true}
/>
);
}
dataContent.push(
<div
data-testid={`popover-fullname-${this.props.user.username}`}
className='overflow--ellipsis pb-1'
key='user-popover-fullname'
>
<span className='user-profile-popover__heading'>{fullname}</span>
{sharedIcon}
</div>,
);
}
if (this.props.user.is_bot && !haveOverrideProp) {
dataContent.push(
<div
key='bot-description'
className='overflow--ellipsis text-nowrap pb-1'
>
{this.props.user.bot_description}
</div>,
);
}
const userNameClass = classNames('overflow--ellipsis pb-1', {'user-profile-popover__heading': fullname === ''});
const userName: React.ReactNode = `@${this.props.user.username}`;
dataContent.push(
<div
id='userPopoverUsername'
className={userNameClass}
key='user-popover-username'
>
{userName}
</div>,
);
if (this.props.user.position && !haveOverrideProp) {
const position = (this.props.user?.position || '').substring(
0,
Constants.MAX_POSITION_LENGTH,
);
dataContent.push(
<div
className='text-center'
key='user-popover-position'
>
{position}
</div>,
);
}
dataContent.push(
<hr
key='user-popover-hr'
className='divider divider--expanded'
/>,
);
const email = this.props.user.email || '';
if (email && !this.props.user.is_bot && !haveOverrideProp) {
dataContent.push(
<div
data-toggle='tooltip'
title={email}
key='user-popover-email'
>
<a
href={'mailto:' + email}
className='text-nowrap text-lowercase user-popover__email pb-1'
>
{email}
</a>
</div>,
);
}
dataContent.push(
<Pluggable
key='profilePopoverPluggable2'
pluggableName='PopoverUserAttributes'
user={this.props.user}
hide={this.props.hide}
status={this.props.hideStatus ? null : this.props.status}
fromWebhook={this.props.fromWebhook}
/>,
);
if (this.props.user.timezone && !haveOverrideProp) {
dataContent.push(
<ProfileTimezone
currentUserTimezone={this.props.currentUserTimezone}
profileUserTimezone={this.props.user.timezone}
key='user-popover-local-time'
/>,
);
}
const customStatusAndExpiryContent = !haveOverrideProp && this.renderCustomStatus();
if (customStatusAndExpiryContent) {
const {customStatusContent, expiryContent} = customStatusAndExpiryContent;
dataContent.push(
<div
key='user-popover-status'
id='user-popover-status'
className='user-popover__time-status-container'
>
<span className='user-popover__subtitle'>
<FormattedMessage
id='user_profile.custom_status'
defaultMessage='Status'
/>
{expiryContent}
</span>
{customStatusContent}
</div>,
);
}
const sendMessageTooltip = (
<Tooltip id='sendMessageTooltip'>
<FormattedMessage
id='user_profile.send.dm.yourself'
defaultMessage='Send yourself a message'
/>
</Tooltip>
);
if (this.props.user.id === this.props.currentUserId && !haveOverrideProp) {
dataContent.push(
<div
data-toggle='tooltip'
key='user-popover-settings'
className='popover__row first'
>
<button
id='editProfileButton'
type='button'
className='btn'
onClick={this.handleEditAccountSettings}
>
<AccountOutlineIcon
size={16}
aria-label={formatMessage({
id: t('generic_icons.edit'),
defaultMessage: 'Edit Icon',
})}
/>
<FormattedMessage
id='user_profile.account.editProfile'
defaultMessage='Edit Profile'
/>
</button>
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
placement='top'
overlay={sendMessageTooltip}
>
<button
type='button'
className='btn icon-btn'
onClick={this.handleShowDirectChannel}
>
<SendIcon
size={18}
aria-label={formatMessage({
id: t('user_profile.send.dm.icon'),
defaultMessage: 'Send Message Icon',
})}
/>
</button>
</OverlayTrigger>
</div>,
);
}
if (haveOverrideProp) {
dataContent.push(
<div
data-toggle='tooltip'
key='user-popover-settings'
className='popover__row first'
>
<FormattedMessage
id='user_profile.account.post_was_created'
defaultMessage='This post was created by an integration from @{username}'
values={{
username: this.props.user.username,
}}
/>
</div>,
);
}
const addToChannelMessage = formatMessage({
id: 'user_profile.add_user_to_channel',
defaultMessage: 'Add to a Channel',
});
const addToChannelButton = (
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
placement='top'
overlay={
<Tooltip id='addToChannelTooltip'>
{addToChannelMessage}
</Tooltip>
}
>
<div>
<ToggleModalButton
id='addToChannelButton'
className='btn icon-btn'
ariaLabel={addToChannelMessage}
modalId={ModalIdentifiers.ADD_USER_TO_CHANNEL}
dialogType={AddUserToChannelModal}
dialogProps={{user: this.props.user, onExited: this.returnFocus}}
onClick={this.handleAddToChannel}
>
<AccountPlusOutlineIcon
size={18}
aria-label={formatMessage({
id: t('user_profile.add_user_to_channel.icon'),
defaultMessage: 'Add User to Channel Icon',
})}
/>
</ToggleModalButton>
</div>
</OverlayTrigger>
);
const renderCallButton = () => {
const {isCallsEnabled, isCallsDefaultEnabledOnAllChannels, isCallsCanBeDisabledOnSpecificChannels, dMChannel} = this.props;
if (
!isCallsEnabled ||
this.state.callsDMChannelState?.enabled === false ||
(!isCallsDefaultEnabledOnAllChannels && !isCallsCanBeDisabledOnSpecificChannels && this.state.callsChannelState?.enabled === false)
) {
return null;
}
const disabled = this.props.isUserInCall || this.props.isCurrentUserInCall;
const startCallMessage = this.props.isUserInCall ? formatMessage({
id: t('user_profile.call.userBusy'),
defaultMessage: '{user} is in another call',
}, {user: fullname === '' ? this.props.user?.username : fullname},
) : formatMessage({
id: t('webapp.mattermost.feature.start_call'),
defaultMessage: 'Start Call',
});
const iconButtonClassname = classNames('btn icon-btn', {'icon-btn-disabled': disabled});
const callButton = (
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
placement='top'
overlay={
<Tooltip id='startCallTooltip'>
{startCallMessage}
</Tooltip>
}
>
<button
id='startCallButton'
type='button'
aria-disabled={disabled}
className={iconButtonClassname}
>
<PhoneInTalkIcon
size={18}
aria-label={startCallMessage}
/>
</button>
</OverlayTrigger>
);
if (disabled) {
return callButton;
}
return (
<ProfilePopoverCallButton
dmChannel={dMChannel}
userId={this.props.userId}
customButton={callButton}
/>
);
};
if (this.props.user.id !== this.props.currentUserId && !haveOverrideProp) {
dataContent.push(
<div
data-toggle='tooltip'
key='user-popover-dm'
className='popover__row first'
>
<button
id='messageButton'
type='button'
className='btn'
onClick={this.handleShowDirectChannel}
>
<SendIcon
size={16}
aria-label={formatMessage({
id: t('user_profile.send.dm.icon'),
defaultMessage: 'Send Message Icon',
})}
/>
<FormattedMessage
id='user_profile.send.dm'
defaultMessage='Message'
/>
</button>
<div className='popover_row-controlContainer'>
{(this.props.canManageAnyChannelMembersInCurrentTeam && this.props.isInCurrentTeam) ? addToChannelButton : null}
{renderCallButton()}
</div>
</div>,
);
}
dataContent.push(
<Pluggable
key='profilePopoverPluggable3'
pluggableName='PopoverUserActions'
user={this.props.user}
hide={this.props.hide}
status={this.props.hideStatus ? null : this.props.status}
/>,
);
let roleTitle;
if (this.props.user.is_bot) {
roleTitle = (
<BotTag
className='user-popover__role'
size={'sm'}
/>
);
} else if (isGuest(this.props.user.roles)) {
roleTitle = (
<GuestTag
className='user-popover__role'
size={'sm'}
/>
);
} else if (isSystemAdmin(this.props.user.roles)) {
roleTitle = (
<Tag
className='user-popover__role'
size={'sm'}
text={Utils.localizeMessage(
'admin.permissions.roles.system_admin.name',
'System Admin',
)}
/>
);
} else if (this.props.isTeamAdmin) {
roleTitle = (
<Tag
className='user-popover__role'
size={'sm'}
text={Utils.localizeMessage(
'admin.permissions.roles.team_admin.name',
'Team Admin',
)}
/>
);
} else if (this.props.isChannelAdmin) {
roleTitle = (
<Tag
className='user-popover__role'
size={'sm'}
text={Utils.localizeMessage(
'admin.permissions.roles.channel_admin.name',
'Channel Admin',
)}
/>
);
}
const title = (
<span data-testid={`profilePopoverTitle_${this.props.user.username}`}>
{roleTitle}
<button
ref={this.closeButtonRef}
className='user-popover__close'
onClick={this.handleClose}
>
<CloseIcon
size={18}
/>
</button>
</span>
);
const displayName = displayUsername(this.props.user, this.props.teammateNameDisplay);
const titleClassName = classNames('popover-title', {'popover-title_height': !roleTitle});
const tabCatcher = (
<span
tabIndex={0}
onFocus={(e) => (e.relatedTarget as HTMLElement).focus()}
/>
);
return (
<Popover
{...popoverProps}
id='user-profile-popover'
>
{tabCatcher}
<div
role='dialog'
aria-label={formatMessage(
{
id: 'profile_popover.profileLabel',
defaultMessage: 'Profile for {name}',
},
{
name: displayName,
},
)}
onKeyDown={this.handleKeyDown}
className={A11yClassNames.POPUP}
aria-modal={true}
>
<div className={titleClassName}>
{title}
</div>
<div className='user-profile-popover__content'>
{dataContent}
</div>
</div>
{tabCatcher}
</Popover>
);
}
}
export default injectIntl(ProfilePopover);

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

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {AccountPlusOutlineIcon} from '@mattermost/compass-icons/components';
import type {UserProfile} from '@mattermost/types/users';
import {canManageAnyChannelMembersInCurrentTeam as getCanManageAnyChannelMembersInCurrentTeam} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeam, getTeamMember} from 'mattermost-redux/selectors/entities/teams';
import AddUserToChannelModal from 'components/add_user_to_channel_modal';
import OverlayTrigger from 'components/overlay_trigger';
import ToggleModalButton from 'components/toggle_modal_button';
import Tooltip from 'components/tooltip';
import Constants, {ModalIdentifiers} from 'utils/constants';
import type {GlobalState} from 'types/store';
type Props = {
user: UserProfile;
returnFocus: () => void;
handleCloseModals: () => void;
hide?: () => void;
};
function getIsInCurrentTeam(state: GlobalState, userId: string) {
const team = getCurrentTeam(state);
const teamMember = getTeamMember(state, team.id, userId);
return Boolean(teamMember) && teamMember?.delete_at === 0;
}
const AddToChannel = ({
handleCloseModals,
returnFocus,
user,
hide,
}: Props) => {
const {formatMessage} = useIntl();
const canManageAnyChannelMembersInCurrentTeam = useSelector((state: GlobalState) => getCanManageAnyChannelMembersInCurrentTeam(state));
const isInCurrentTeam = useSelector((state: GlobalState) => getIsInCurrentTeam(state, user.id));
const handleAddToChannel = useCallback(() => {
hide?.();
handleCloseModals();
}, [hide, handleCloseModals]);
if (!canManageAnyChannelMembersInCurrentTeam || !isInCurrentTeam) {
return null;
}
const addToChannelMessage = formatMessage({
id: 'user_profile.add_user_to_channel',
defaultMessage: 'Add to a Channel',
});
return (
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
placement='top'
overlay={
<Tooltip id='addToChannelTooltip'>
{addToChannelMessage}
</Tooltip>
}
>
<div>
<ToggleModalButton
id='addToChannelButton'
className='btn icon-btn'
ariaLabel={addToChannelMessage}
modalId={ModalIdentifiers.ADD_USER_TO_CHANNEL}
dialogType={AddUserToChannelModal}
dialogProps={{user, onExited: returnFocus}}
onClick={handleAddToChannel}
>
<AccountPlusOutlineIcon
size={18}
aria-label={formatMessage({
id: 'user_profile.add_user_to_channel.icon',
defaultMessage: 'Add User to Channel Icon',
})}
/>
</ToggleModalButton>
</div>
</OverlayTrigger>
);
};
export default AddToChannel;

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

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {checkUserInCall} from './call_button';
describe('checkUserInCall', () => {
test('missing state', () => {
expect(checkUserInCall({
'plugins-com.mattermost.calls': {},
} as any, 'userA')).toBe(false);
});
test('call state missing', () => {
expect(checkUserInCall({
'plugins-com.mattermost.calls': {
profiles: {
channelID: null,
},
},
} as any, 'userA')).toBe(false);
});
test('user not in call', () => {
expect(checkUserInCall({
'plugins-com.mattermost.calls': {
profiles: {
channelID: {
sessionB: {
id: 'userB',
},
},
},
},
} as any, 'userA')).toBe(false);
});
test('user in call', () => {
expect(checkUserInCall({
'plugins-com.mattermost.calls': {
profiles: {
channelID: {
sessionB: {
id: 'userB',
},
sessionA: {
id: 'userA',
},
},
},
},
} as any, 'userA')).toBe(true);
});
});

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

@@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {PhoneInTalkIcon} from '@mattermost/compass-icons/components';
import {Client4} from 'mattermost-redux/client';
import {getChannelByName} from 'mattermost-redux/selectors/entities/channels';
import {getCallsConfig, getProfilesInCalls} from 'mattermost-redux/selectors/entities/common';
import {isCallsEnabled as getIsCallsEnabled} from 'selectors/calls';
import OverlayTrigger from 'components/overlay_trigger';
import ProfilePopoverCallButton from 'components/profile_popover_call_button';
import Tooltip from 'components/tooltip';
import Constants from 'utils/constants';
import {getDirectChannelName} from 'utils/utils';
import type {GlobalState} from 'types/store';
type Props = {
userId: string;
currentUserId: string;
channelId?: string;
fullname: string;
username: string;
}
type ChannelCallsState = {
enabled: boolean;
id: string;
};
export function checkUserInCall(state: GlobalState, userId: string) {
for (const profilesMap of Object.values(getProfilesInCalls(state))) {
for (const profile of Object.values(profilesMap || {})) {
if (profile?.id === userId) {
return true;
}
}
}
return false;
}
const CallButton = ({
userId,
currentUserId,
channelId,
fullname,
username,
}: Props) => {
const {formatMessage} = useIntl();
const isCallsEnabled = useSelector((state: GlobalState) => getIsCallsEnabled(state));
const isUserInCall = useSelector((state: GlobalState) => (isCallsEnabled ? checkUserInCall(state, userId) : undefined));
const isCurrentUserInCall = useSelector((state: GlobalState) => (isCallsEnabled ? checkUserInCall(state, currentUserId) : undefined));
const callsConfig = useSelector((state: GlobalState) => (isCallsEnabled ? getCallsConfig(state) : undefined));
const isCallsDefaultEnabledOnAllChannels = callsConfig?.DefaultEnabled;
const isCallsCanBeDisabledOnSpecificChannels = callsConfig?.AllowEnableCalls;
const dmChannel = useSelector((state: GlobalState) => getChannelByName(state, getDirectChannelName(currentUserId, userId)));
const [callsDMChannelState, setCallsDMChannelState] = useState<ChannelCallsState>();
const [callsChannelState, setCallsChannelState] = useState<ChannelCallsState>();
const getCallsChannelState = useCallback((channelId: string): Promise<ChannelCallsState> => {
let data: Promise<ChannelCallsState>;
try {
data = Client4.getCallsChannelState(channelId);
} catch (error) {
return error;
}
return data;
}, []);
useEffect(() => {
if (isCallsEnabled && dmChannel) {
getCallsChannelState(dmChannel.id).then((data) => {
setCallsDMChannelState(data);
});
}
if (isCallsEnabled && channelId) {
getCallsChannelState(channelId).then((data) => {
setCallsChannelState(data);
});
}
}, []);
if (
!isCallsEnabled ||
callsDMChannelState?.enabled === false ||
(!isCallsDefaultEnabledOnAllChannels && !isCallsCanBeDisabledOnSpecificChannels && callsChannelState?.enabled === false)
) {
return null;
}
const disabled = isUserInCall || isCurrentUserInCall;
const startCallMessage = isUserInCall ? formatMessage({
id: 'user_profile.call.userBusy',
defaultMessage: '{user} is in another call',
}, {user: fullname || username},
) : formatMessage({
id: 'webapp.mattermost.feature.start_call',
defaultMessage: 'Start Call',
});
const iconButtonClassname = classNames('btn icon-btn', {'icon-btn-disabled': disabled});
const callButton = (
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
placement='top'
overlay={
<Tooltip id='startCallTooltip'>
{startCallMessage}
</Tooltip>
}
>
<button
id='startCallButton'
type='button'
aria-disabled={disabled}
className={iconButtonClassname}
>
<PhoneInTalkIcon
size={18}
aria-label={startCallMessage}
/>
</button>
</OverlayTrigger>
);
if (disabled) {
return callButton;
}
return (
<ProfilePopoverCallButton
dmChannel={dmChannel}
userId={userId}
customButton={callButton}
/>
);
};
export default CallButton;

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

@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {SendIcon} from '@mattermost/compass-icons/components';
import type {UserProfile} from '@mattermost/types/users';
import AddToChannel from './add_to_channel';
import CallButton from './call_button';
type Props = {
user: UserProfile;
fullname: string;
channelId?: string;
currentUserId: string;
haveOverrideProp: boolean;
handleShowDirectChannel: (e: React.MouseEvent<HTMLButtonElement>) => void;
returnFocus: () => void;
handleCloseModals: () => void;
hide?: () => void;
};
const ProfilePopoverActions = ({
currentUserId,
haveOverrideProp,
user,
handleShowDirectChannel,
handleCloseModals,
returnFocus,
hide,
fullname,
channelId,
}: Props) => {
const {formatMessage} = useIntl();
if (user.id === currentUserId || haveOverrideProp) {
return null;
}
return (
<div
data-toggle='tooltip'
className='popover__row first'
>
<button
id='messageButton'
type='button'
className='btn'
onClick={handleShowDirectChannel}
>
<SendIcon
size={16}
aria-label={formatMessage({
id: 'user_profile.send.dm.icon',
defaultMessage: 'Send Message Icon',
})}
/>
<FormattedMessage
id='user_profile.send.dm'
defaultMessage='Message'
/>
</button>
<div className='popover_row-controlContainer'>
<AddToChannel
handleCloseModals={handleCloseModals}
returnFocus={returnFocus}
user={user}
hide={hide}
/>
<CallButton
channelId={channelId}
currentUserId={currentUserId}
fullname={fullname}
userId={user.id}
username={user.username}
/>
</div>
</div>
);
};
export default ProfilePopoverActions;

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

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import StatusIcon from 'components/status_icon';
import Avatar from 'components/widgets/users/avatar';
type Props = {
username?: string;
hideStatus?: boolean;
status?: string;
urlSrc: string;
}
const ProfilePopoverAvatar = ({
username,
hideStatus,
status,
urlSrc,
}: Props) => {
return (
<div className='user-popover-image'>
<Avatar
id='userAvatar'
size='xxl'
username={username}
url={urlSrc}
tabIndex={-1}
/>
<StatusIcon
className='status user-popover-status'
status={hideStatus ? undefined : status}
button={true}
/>
</div>
);
};
export default ProfilePopoverAvatar;

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

@@ -0,0 +1,127 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {EmoticonHappyOutlineIcon} from '@mattermost/compass-icons/components';
import type {UserProfile} from '@mattermost/types/users';
import {CustomStatusDuration} from '@mattermost/types/users';
import {openModal} from 'actions/views/modals';
import {makeGetCustomStatus, isCustomStatusEnabled as getIsCustomStatusEnabled, isCustomStatusExpired as getIsCustomStatusExpired} from 'selectors/views/custom_status';
import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
import CustomStatusModal from 'components/custom_status/custom_status_modal';
import CustomStatusText from 'components/custom_status/custom_status_text';
import ExpiryTime from 'components/custom_status/expiry_time';
import {ModalIdentifiers} from 'utils/constants';
import type {GlobalState} from 'types/store';
type Props = {
user: UserProfile;
currentUserId: string;
hideStatus?: boolean;
hide?: () => void;
returnFocus?: () => void;
currentUserTimezone?: string;
haveOverrideProp: boolean;
}
const emojiStyles: React.CSSProperties = {
marginRight: 4,
marginTop: 1,
};
const ProfilePopoverCustomStatus = ({
currentUserId,
hideStatus,
user,
hide,
returnFocus,
currentUserTimezone,
haveOverrideProp,
}: Props) => {
const dispatch = useDispatch();
const getCustomStatus = useMemo(makeGetCustomStatus, []);
const customStatus = useSelector((state: GlobalState) => getCustomStatus(state, user.id));
const isCustomStatusExpired = useSelector((state: GlobalState) => getIsCustomStatusExpired(state, customStatus));
const isCustomStatusEnabled = useSelector((state: GlobalState) => getIsCustomStatusEnabled(state));
const showCustomStatusModal = useCallback(() => {
hide?.();
const customStatusInputModalData = {
modalId: ModalIdentifiers.CUSTOM_STATUS,
dialogType: CustomStatusModal,
dialogProps: {onExited: returnFocus},
};
dispatch(openModal(customStatusInputModalData));
}, [hide, returnFocus]);
const customStatusSet = (customStatus?.text || customStatus?.emoji) && !isCustomStatusExpired;
const showExpiryTime = customStatusSet && customStatus.expires_at && customStatus.duration !== CustomStatusDuration.DONT_CLEAR;
const canSetCustomStatus = user.id === currentUserId;
const shouldShowCustomStatus = isCustomStatusEnabled && !haveOverrideProp && !hideStatus && (customStatusSet || canSetCustomStatus);
if (!shouldShowCustomStatus) {
return null;
}
let customStatusContent;
if (customStatusSet) {
customStatusContent = (
<div className='d-flex align-items-center'>
<CustomStatusEmoji
userID={user.id}
showTooltip={false}
emojiStyle={emojiStyles}
/>
<CustomStatusText
tooltipDirection='top'
text={customStatus.text || ''}
className='user-popover__email'
/>
</div>
);
} else if (canSetCustomStatus) {
customStatusContent = (
<button
className='user-popover__set-custom-status-btn'
onClick={showCustomStatusModal}
>
<EmoticonHappyOutlineIcon size={14}/>
<FormattedMessage
id='user_profile.custom_status.set_status'
defaultMessage='Set a status'
/>
</button>
);
}
return (
<div
id='user-popover-status'
className='user-popover__time-status-container'
>
<span className='user-popover__subtitle'>
<FormattedMessage
id='user_profile.custom_status'
defaultMessage='Status'
/>
{showExpiryTime && (
<ExpiryTime
time={customStatus.expires_at!} // has to be defined since showExpiryTime is true
timezone={currentUserTimezone}
className='ml-1'
withinBrackets={true}
/>
)}
</span>
{customStatusContent}
</div>
);
};
export default ProfilePopoverCustomStatus;

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

@@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {AccountOutlineIcon, SendIcon} from '@mattermost/compass-icons/components';
import {openModal} from 'actions/views/modals';
import OverlayTrigger from 'components/overlay_trigger';
import Tooltip from 'components/tooltip';
import UserSettingsModal from 'components/user_settings/modal';
import Constants, {ModalIdentifiers} from 'utils/constants';
type Props = {
userId: string;
currentUserId: string;
haveOverrideProp: boolean;
hide?: () => void;
returnFocus: () => void;
handleCloseModals: () => void;
handleShowDirectChannel: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
const ProfilePopoverEdit = ({
userId,
currentUserId,
haveOverrideProp,
hide,
returnFocus,
handleCloseModals,
handleShowDirectChannel,
}: Props) => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const handleEditAccountSettings = useCallback(() => {
hide?.();
dispatch(openModal({
modalId: ModalIdentifiers.USER_SETTINGS,
dialogType: UserSettingsModal,
dialogProps: {isContentProductSettings: false, onExited: returnFocus},
}));
handleCloseModals();
}, [hide, returnFocus, handleCloseModals]);
if (userId !== currentUserId || haveOverrideProp) {
return null;
}
const sendMessageTooltip = (
<Tooltip id='sendMessageTooltip'>
<FormattedMessage
id='user_profile.send.dm.yourself'
defaultMessage='Send yourself a message'
/>
</Tooltip>
);
return (
<div
data-toggle='tooltip'
className='popover__row first'
>
<button
id='editProfileButton'
type='button'
className='btn'
onClick={handleEditAccountSettings}
>
<AccountOutlineIcon
size={16}
aria-label={formatMessage({
id: 'generic_icons.edit',
defaultMessage: 'Edit Icon',
})}
/>
<FormattedMessage
id='user_profile.account.editProfile'
defaultMessage='Edit Profile'
/>
</button>
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
placement='top'
overlay={sendMessageTooltip}
>
<button
type='button'
className='btn icon-btn'
onClick={handleShowDirectChannel}
>
<SendIcon
size={18}
aria-label={formatMessage({
id: 'user_profile.send.dm.icon',
defaultMessage: 'Send Message Icon',
})}
/>
</button>
</OverlayTrigger>
</div>
);
};
export default ProfilePopoverEdit;

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

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
type Props = {
email: string;
haveOverrideProp: boolean;
isBot: boolean;
}
const ProfilePopoverEmail = ({
email,
haveOverrideProp,
isBot,
}: Props) => {
if (!email || isBot || haveOverrideProp) {
return null;
}
return (
<div
data-toggle='tooltip'
title={email}
>
<a
href={'mailto:' + email}
className='text-nowrap text-lowercase user-popover__email pb-1'
>
{email}
</a>
</div>
);
};
export default ProfilePopoverEmail;

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

@@ -0,0 +1,48 @@
// 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 {displayLastActiveLabel, getLastActiveTimestampUnits, getLastActivityForUserId} from 'mattermost-redux/selectors/entities/users';
import Timestamp from 'components/timestamp';
import type {GlobalState} from 'types/store';
type Props = {
userId: string;
}
const ProfilePopoverLastActive = ({
userId,
}: Props) => {
const lastActivityTimestamp = useSelector((state: GlobalState) => getLastActivityForUserId(state, userId));
const enableLastActiveTime = useSelector<GlobalState>((state) => displayLastActiveLabel(state, userId));
const timestampUnits = useSelector((state: GlobalState) => getLastActiveTimestampUnits(state, userId));
if (!enableLastActiveTime) {
return null;
}
return (
<div className='user-popover-last-active'>
<FormattedMessage
id='channel_header.lastOnline'
defaultMessage='Last online {timestamp}'
values={{
timestamp: (
<Timestamp
value={lastActivityTimestamp}
units={timestampUnits}
useTime={false}
style={'short'}
/>
),
}}
/>
</div>
);
};
export default ProfilePopoverLastActive;

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

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
type Props = {
isBot: boolean;
haveOverrideProp: boolean;
botDescription: string;
}
const BotDescription = ({
haveOverrideProp,
isBot,
botDescription,
}: Props) => {
if (!isBot || haveOverrideProp) {
return null;
}
return (
<div className='overflow--ellipsis text-nowrap pb-1'>
{botDescription}
</div>
);
};
export default BotDescription;

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

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import SharedUserIndicator from 'components/shared_user_indicator';
type Props = {
fullname: string;
username: string;
remoteId?: string;
}
const FullName = ({
fullname,
username,
remoteId,
}: Props) => {
if (!fullname) {
return null;
}
let sharedIcon;
if (remoteId) {
sharedIcon = (
<SharedUserIndicator
className='shared-user-icon'
withTooltip={true}
/>
);
}
return (
<div
data-testid={`popover-fullname-${username}`}
className='overflow--ellipsis pb-1'
>
<span className='user-profile-popover__heading'>{fullname}</span>
{sharedIcon}
</div>
);
};
export default FullName;

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

@@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {UserProfile} from '@mattermost/types/users';
import BotDescription from './bot_description';
import FullName from './full_name';
import Position from './position';
import UserName from './user_name';
type Props = {
haveOverrideProp: boolean;
user: UserProfile;
fullname: string;
}
const ProfilePopoverName = ({
user,
haveOverrideProp,
fullname,
}: Props) => {
return (
<>
<FullName
fullname={fullname}
remoteId={user.remote_id}
username={user.username}
/>
<BotDescription
botDescription={user.bot_description}
haveOverrideProp={haveOverrideProp}
isBot={user.is_bot}
/>
<UserName
hasFullName={Boolean(fullname)}
username={user.username}
/>
<Position
haveOverrideProp={haveOverrideProp}
position={user.position}
/>
</>
);
};
export default ProfilePopoverName;

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

@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import Constants from 'utils/constants';
type Props = {
position: string;
haveOverrideProp: boolean;
}
const Position = ({
position,
haveOverrideProp,
}: Props) => {
if (!position || haveOverrideProp) {
return null;
}
const positionToRender = (position).substring(
0,
Constants.MAX_POSITION_LENGTH,
);
return (
<div className='text-center'>
{positionToRender}
</div>
);
};
export default Position;

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

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
type Props = {
hasFullName: boolean;
username: string;
}
const UserName = ({
hasFullName,
username,
}: Props) => {
const userNameClass = classNames('overflow--ellipsis pb-1', {'user-profile-popover__heading': hasFullName});
return (
<div
id='userPopoverUsername'
className={userNameClass}
>
{`@${username}`}
</div>
);
};
export default UserName;

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

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
type Props = {
username: string;
haveOverrideProp: boolean;
}
const ProfilePopoverOverrideDisclaimer = ({
username,
haveOverrideProp,
}: Props) => {
if (!haveOverrideProp) {
return null;
}
return (
<div
data-toggle='tooltip'
className='popover__row first'
>
<FormattedMessage
id='user_profile.account.post_was_created'
defaultMessage='This post was created by an integration from @{username}'
values={{
username,
}}
/>
</div>
);
};
export default ProfilePopoverOverrideDisclaimer;

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

@@ -0,0 +1,144 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {CloseIcon} from '@mattermost/compass-icons/components';
import {getChannelMember} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeam, getTeamMember} from 'mattermost-redux/selectors/entities/teams';
import {isGuest, isSystemAdmin} from 'mattermost-redux/utils/user_utils';
import {getRhsState} from 'selectors/rhs';
import BotTag from 'components/widgets/tag/bot_tag';
import GuestTag from 'components/widgets/tag/guest_tag';
import Tag from 'components/widgets/tag/tag';
import type {GlobalState} from 'types/store';
type Props = {
isBot: boolean;
roles: string;
username: string;
returnFocus: () => void;
hide?: () => void;
userId: string;
channelId?: string;
closeButtonRef: React.RefObject<HTMLButtonElement>;
}
function getIsTeamAdmin(state: GlobalState, userId: string) {
const team = getCurrentTeam(state);
const teamMember = getTeamMember(state, team.id, userId);
return Boolean(teamMember && teamMember.scheme_admin);
}
function getIsChannelAdmin(state: GlobalState, userId: string, channelId?: string) {
if (!channelId) {
return false;
}
const channelMember = getChannelMember(state, channelId, userId);
if (getRhsState(state) !== 'search' && channelMember != null && channelMember.scheme_admin) {
return true;
}
return false;
}
const ProfilePopoverTitle = ({
isBot,
roles,
username,
returnFocus,
hide,
userId,
channelId,
closeButtonRef,
}: Props) => {
const {formatMessage} = useIntl();
const isTeamAdmin = useSelector((state: GlobalState) => getIsTeamAdmin(state, userId));
const isChannelAdmin = useSelector((state: GlobalState) => getIsChannelAdmin(state, userId, channelId));
const handleClose = useCallback(() => {
hide?.();
returnFocus();
}, [hide, returnFocus]);
let roleTitle;
if (isBot) {
roleTitle = (
<BotTag
className='user-popover__role'
size={'sm'}
/>
);
} else if (isGuest(roles)) {
roleTitle = (
<GuestTag
className='user-popover__role'
size={'sm'}
/>
);
} else if (isSystemAdmin(roles)) {
roleTitle = (
<Tag
className='user-popover__role'
size={'sm'}
text={formatMessage({
id: 'admin.permissions.roles.system_admin.name',
defaultMessage: 'System Admin',
})}
/>
);
} else if (isTeamAdmin) {
roleTitle = (
<Tag
className='user-popover__role'
size={'sm'}
text={formatMessage({
id: 'admin.permissions.roles.team_admin.name',
defaultMessage: 'Team Admin',
})}
/>
);
} else if (isChannelAdmin) {
roleTitle = (
<Tag
className='user-popover__role'
size={'sm'}
text={formatMessage({
id: 'admin.permissions.roles.channel_admin.name',
defaultMessage: 'Channel Admin',
})}
/>
);
}
const titleClassName = classNames('popover-title', {'popover-title_height': !roleTitle});
return (
<div className={titleClassName}>
<span data-testid={`profilePopoverTitle_${username}`}>
{roleTitle}
<button
ref={closeButtonRef}
className='user-popover__close'
onClick={handleClose}
>
<CloseIcon
size={18}
/>
</button>
</span>
</div>
);
};
export default ProfilePopoverTitle;

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

@@ -14,6 +14,7 @@ import Timestamp from 'components/timestamp';
type ProfileTimezoneProps = {
profileUserTimezone?: UserTimezone;
currentUserTimezone: string | undefined;
haveOverrideProp: boolean;
}
const returnTimeDiff = (
@@ -51,12 +52,15 @@ const returnTimeDiff = (
);
};
export const ProfileTimezone = (
{
currentUserTimezone,
profileUserTimezone,
}: ProfileTimezoneProps,
) => {
const ProfileTimezone = ({
currentUserTimezone,
profileUserTimezone,
haveOverrideProp,
}: ProfileTimezoneProps) => {
if (haveOverrideProp || !profileUserTimezone) {
return null;
}
const profileTimezone = getUserCurrentTimezone(profileUserTimezone) || 'UTC';
const profileTimezoneShort = profileTimezone ? DateTime.now().setZone(profileTimezone).offsetNameShort : undefined;
@@ -98,3 +102,5 @@ export const ProfileTimezone = (
</div>
);
};
export default ProfileTimezone;

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

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {FormattedMessage, useIntl} from 'react-intl';
import OverlayTrigger from 'components/overlay_trigger';
import Tooltip from 'components/tooltip';
@@ -15,7 +15,14 @@ type Props = {
};
const SharedUserIndicator: React.FC<Props> = (props: Props): JSX.Element => {
const sharedIcon = (<i className={`${props.className || ''} icon-circle-multiple-outline`}/>);
const intl = useIntl();
const sharedIcon = (
<i
className={`${props.className || ''} icon-circle-multiple-outline`}
aria-label={intl.formatMessage({id: 'shared_user_indicator.aria_label', defaultMessage: 'shared user indicator'})}
/>
);
if (!props.withTooltip) {
return sharedIcon;

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

@@ -86,7 +86,7 @@ exports[`components/threading/channel_threads/thread_footer should report total
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/5/image?_=0"
@@ -312,7 +312,7 @@ exports[`components/threading/channel_threads/thread_footer should report total
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/4/image?_=0"
@@ -538,7 +538,7 @@ exports[`components/threading/channel_threads/thread_footer should report total
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/3/image?_=0"
@@ -1241,7 +1241,7 @@ exports[`components/threading/channel_threads/thread_footer should show unread i
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/5/image?_=0"
@@ -1467,7 +1467,7 @@ exports[`components/threading/channel_threads/thread_footer should show unread i
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/4/image?_=0"
@@ -1693,7 +1693,7 @@ exports[`components/threading/channel_threads/thread_footer should show unread i
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/3/image?_=0"

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

@@ -130,7 +130,6 @@ const UserListRow = ({user, status, extraInfo = [], actions = [], actionProps, a
>
<UserProfile
userId={user.id}
hasMention={true}
displayUsername={true}
/>
{

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

@@ -5,12 +5,10 @@ exports[`components/UserProfile should match snapshot 1`] = `
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hasMention={false}
hide={[Function]}
hideStatus={false}
isBusy={false}
overwriteName=""
src="/api/v4/users/undefined/image?_=0"
userId="user_id"
@@ -39,12 +37,10 @@ exports[`components/UserProfile should match snapshot, when displayUsername is e
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hasMention={false}
hide={[Function]}
hideStatus={false}
isBusy={false}
overwriteName=""
src="/api/v4/users/undefined/image?_=0"
userId="user_id"
@@ -81,12 +77,10 @@ exports[`components/UserProfile should match snapshot, when user is shared 1`] =
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hasMention={false}
hide={[Function]}
hideStatus={false}
isBusy={false}
overwriteName=""
src="/api/v4/users/undefined/image?_=0"
userId="user_id"
@@ -119,12 +113,10 @@ exports[`components/UserProfile should match snapshot, with colorization 1`] = `
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hasMention={false}
hide={[Function]}
hideStatus={false}
isBusy={false}
overwriteName=""
src="/api/v4/users/undefined/image?_=0"
userId="user_id"

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

@@ -23,14 +23,12 @@ export type Props = {
user?: UserProfileType;
userId: string;
displayName?: string;
isBusy?: boolean;
isShared?: boolean;
overwriteName?: React.ReactNode;
overwriteName?: string;
overwriteIcon?: string;
disablePopover?: boolean;
displayUsername?: boolean;
colorize?: boolean;
hasMention?: boolean;
hideStatus?: boolean;
isMobileView: boolean;
isRHS?: boolean;
@@ -44,7 +42,6 @@ export default class UserProfile extends PureComponent<Props> {
static defaultProps: Partial<Props> = {
disablePopover: false,
displayUsername: false,
hasMention: false,
hideStatus: false,
isRHS: false,
overwriteName: '',
@@ -66,11 +63,9 @@ export default class UserProfile extends PureComponent<Props> {
disablePopover,
displayName,
displayUsername,
isBusy,
isMobileView,
isRHS,
isShared,
hasMention,
hideStatus,
overwriteName,
overwriteIcon,
@@ -142,10 +137,8 @@ export default class UserProfile extends PureComponent<Props> {
userId={userId}
channelId={channelId}
src={profileImg}
isBusy={isBusy}
hide={this.hideProfilePopover}
hideStatus={hideStatus}
hasMention={hasMention}
overwriteName={overwriteName}
overwriteIcon={overwriteIcon}
/>

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

@@ -40,7 +40,7 @@ exports[`components/widgets/users/Avatars should fetch missing users 1`] = `
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/1/image?_=0"
@@ -266,7 +266,7 @@ exports[`components/widgets/users/Avatars should fetch missing users 1`] = `
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/6/image?_=0"
@@ -492,7 +492,7 @@ exports[`components/widgets/users/Avatars should fetch missing users 1`] = `
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/7/image?_=0"
@@ -862,7 +862,7 @@ exports[`components/widgets/users/Avatars should properly count overflow 1`] = `
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/1/image?_=0"
@@ -1088,7 +1088,7 @@ exports[`components/widgets/users/Avatars should properly count overflow 1`] = `
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/2/image?_=0"
@@ -1314,7 +1314,7 @@ exports[`components/widgets/users/Avatars should properly count overflow 1`] = `
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/3/image?_=0"
@@ -1682,7 +1682,7 @@ exports[`components/widgets/users/Avatars should support userIds 1`] = `
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/1/image?_=0"
@@ -1908,7 +1908,7 @@ exports[`components/widgets/users/Avatars should support userIds 1`] = `
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/2/image?_=0"
@@ -2134,7 +2134,7 @@ exports[`components/widgets/users/Avatars should support userIds 1`] = `
defaultOverlayShown={false}
disabled={false}
overlay={
<Memo(Connect(injectIntl(ProfilePopover)))
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/3/image?_=0"

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

@@ -4798,6 +4798,7 @@
"setting_picture.select": "Select",
"setting_picture.uploading": "Uploading...",
"shared_channel_indicator.tooltip": "Shared with trusted organizations",
"shared_user_indicator.aria_label": "shared user indicator",
"shared_user_indicator.tooltip": "From a trusted organization",
"shortcuts.browser.channel_next": "Forward in history:\tAlt|Right",
"shortcuts.browser.channel_next.mac": "Forward in history:\t⌘|]",

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

@@ -20,7 +20,7 @@ import {isDateWithinDaysRange, TimeInformation} from 'utils/utils';
import type {GlobalState} from 'types/store';
export function makeGetCustomStatus(): (state: GlobalState, userID?: string) => UserCustomStatus {
export function makeGetCustomStatus(): (state: GlobalState, userID?: string) => UserCustomStatus | undefined {
return createSelector(
'makeGetCustomStatus',
(state: GlobalState, userID?: string) => (userID ? getUser(state, userID) : getCurrentUser(state)),

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

@@ -22,9 +22,10 @@ window.Observable = Observable;
const localForage = extendPrototype(baseLocalForage);
export default function configureStore(preloadedState) {
export default function configureStore(preloadedState, additionalReducers) {
const reducers = additionalReducers ? {...appReducers, ...additionalReducers} : appReducers;
const store = configureServiceStore({
appReducers,
appReducers: reducers,
getAppReducers,
preloadedState,
});

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

@@ -8,6 +8,7 @@ import React from 'react';
import {IntlProvider} from 'react-intl';
import {Provider} from 'react-redux';
import {Router} from 'react-router-dom';
import type {Reducer} from 'redux';
import type {DeepPartial} from '@mattermost/types/utilities';
@@ -26,6 +27,7 @@ export type FullContextOptions = {
intlMessages?: Record<string, string>;
locale?: string;
useMockedStore?: boolean;
pluginReducers?: string[];
}
export const renderWithContext = (
@@ -39,7 +41,7 @@ export const renderWithContext = (
useMockedStore: partialOptions?.useMockedStore ?? false,
};
const testStore = configureOrMockStore(initialState, options.useMockedStore);
const testStore = configureOrMockStore(initialState, options.useMockedStore, partialOptions?.pluginReducers);
// Store these in an object so that they can be maintained through rerenders
const renderState = {
@@ -82,7 +84,7 @@ export const renderWithContext = (
* Rerenders the component after replacing the entire store state with the provided one.
*/
replaceStoreState: (newInitialState: DeepPartial<GlobalState>) => {
renderState.store = configureOrMockStore(newInitialState, renderState.options.useMockedStore);
renderState.store = configureOrMockStore(newInitialState, renderState.options.useMockedStore, partialOptions?.pluginReducers);
results.rerender(renderState.component);
},
@@ -92,15 +94,25 @@ export const renderWithContext = (
*/
updateStoreState: (stateDiff: DeepPartial<GlobalState>) => {
const newInitialState = mergeObjects(renderState.store.getState(), stateDiff);
renderState.store = configureOrMockStore(newInitialState, renderState.options.useMockedStore);
renderState.store = configureOrMockStore(newInitialState, renderState.options.useMockedStore, partialOptions?.pluginReducers);
results.rerender(renderState.component);
},
};
};
function configureOrMockStore<T>(initialState: DeepPartial<T>, useMockedStore: boolean) {
let testStore = configureStore(initialState);
function configureOrMockStore<T>(initialState: DeepPartial<T>, useMockedStore: boolean, extraReducersKeys?: string[]) {
let testReducers;
if (extraReducersKeys) {
const newReducers: Record<string, Reducer> = {};
extraReducersKeys.forEach((v) => {
newReducers[v] = (state = null) => state;
});
testReducers = newReducers;
}
let testStore = configureStore(initialState, testReducers);
if (useMockedStore) {
testStore = mockStore(testStore.getState());
}

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

@@ -11,7 +11,6 @@ exports[`messageHtmlToComponent At mention 1`] = `
<Memo(Connect(Component))
disableGroupHighlight={false}
disableHighlight={false}
hasMention={true}
mentionName="joram"
>
@joram
@@ -29,7 +28,6 @@ exports[`messageHtmlToComponent At mention 2`] = `
<Memo(Connect(Component))
disableGroupHighlight={false}
disableHighlight={true}
hasMention={true}
mentionName="joram"
>
@joram
@@ -46,7 +44,6 @@ exports[`messageHtmlToComponent At mention with group highlight disabled 1`] = `
<Memo(Connect(Component))
disableGroupHighlight={false}
disableHighlight={false}
hasMention={true}
mentionName="developers"
>
@developers
@@ -63,7 +60,6 @@ exports[`messageHtmlToComponent At mention with group highlight disabled 2`] = `
<Memo(Connect(Component))
disableGroupHighlight={true}
disableHighlight={false}
hasMention={true}
mentionName="developers"
>
@developers

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

@@ -129,7 +129,6 @@ export function messageHtmlToComponent(html: string, options: Options = {}) {
const callAtMention = (
<AtMention
mentionName={mentionName}
hasMention={true}
disableHighlight={!mentionHighlight}
disableGroupHighlight={disableGroupHighlight}
channelId={options.channelId}