[MM-57383] User profile popover performance improved and redesigned (#26420)

Этот коммит содержится в:
M-ZubairAhmed
2024-05-15 07:35:25 +00:00
коммит произвёл GitHub
родитель 4fbc96ec02
Коммит 0911e4dee7
91 изменённых файлов: 4932 добавлений и 7148 удалений

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

@@ -50,15 +50,18 @@ describe('Verify Accessibility Support in Different Images', () => {
// # Open profile popover
cy.getLastPostId().then((postId) => {
cy.get(`#post_${postId}`).within(() => {
cy.get('.user-popover').click();
cy.get('.status-wrapper').click();
});
// * Verify image alt in profile popover
cy.get('#user-profile-popover').within(() => {
cy.get('.user-profile-popover').within(() => {
cy.get('.Avatar').should('have.attr', 'alt', `${otherUser.username} profile image`);
});
});
// # Close the profile popover
cy.get('body').click();
// # Open Settings > Display > Themes
cy.uiOpenSettingsModal('Display').within(() => {
cy.get('#displayButton').click();

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

@@ -167,7 +167,7 @@ describe('Verify Accessibility Support in Post', () => {
cy.focused().tab();
// * Verify focus is on the username
cy.get('button.user-popover').should('be.focused').and('have.attr', 'aria-label', otherUser.username);
cy.get('button.user-popover').should('be.focused');
cy.focused().tab();
// * Verify focus is on the time
@@ -188,10 +188,6 @@ describe('Verify Accessibility Support in Post', () => {
cy.get(`#CENTER_flagIcon_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'save message');
cy.focused().tab();
// * Verify focus is on message actions button
cy.get(`#CENTER_actions_button_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'actions');
cy.focused().tab();
// * Verify focus is on the comment button
cy.get(`#CENTER_commentIcon_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'reply');
cy.focused().tab();
@@ -238,10 +234,6 @@ describe('Verify Accessibility Support in Post', () => {
cy.get(`#RHS_COMMENT_button_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'more');
cy.focused().tab({shift: true});
// * Verify focus is on message actions button
cy.get(`#RHS_COMMENT_actions_button_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'actions');
cy.focused().tab({shift: true});
// * Verify focus is on the save icon
cy.get(`#RHS_COMMENT_flagIcon_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'save message');
cy.focused().tab({shift: true});
@@ -259,7 +251,7 @@ describe('Verify Accessibility Support in Post', () => {
cy.focused().tab({shift: true});
// * Verify focus is on the username
cy.get('button.user-popover').should('be.focused').and('have.attr', 'aria-label', otherUser.username);
cy.get('button.user-popover').should('be.focused');
cy.focused().tab({shift: true});
});
});

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

@@ -45,7 +45,7 @@ describe('Profile > Profile Settings > Position', () => {
cy.get('.profile-icon > img').as('profileIconForPopover').click();
// # Verify that the popover is visible and contains position
cy.contains('#user-profile-popover', position).should('be.visible');
cy.contains('div.user-profile-popover', position).should('be.visible');
});
it('MM-T2064 Position / 128 characters', () => {

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

@@ -60,10 +60,11 @@ describe('Profile > Profile Settings> Full Name', () => {
cy.getLastPostId().then((postId) => {
cy.get(`#post_${postId}`).should('be.visible');
cy.get(`#post_${postId} img`).click();
cy.get('#user-profile-popover').should('be.visible');
cy.get('div.user-profile-popover').should('be.visible');
cy.get('button.closeButtonRelativePosition').click();
// * Popover user name should show truncated to 'This Is a Long Name That Should Tr...'
cy.findByTestId(`popover-fullname-${firstUser.username}`).should('have.css', 'text-overflow', 'ellipsis');
cy.findByTestId(`popover-fullname-${firstUser.username}`).should('have.css', 'text-overflow', 'clip');
});
});

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

@@ -205,7 +205,7 @@ describe('Settings > Sidebar > General > Edit', () => {
});
// * Verify username in profile popover
cy.get('#user-profile-popover').within(() => {
cy.get('div.user-profile-popover').within(() => {
cy.get('#userPopoverUsername').should('be.visible').and('contain', `${testUser.username}`);
});
});
@@ -238,7 +238,7 @@ describe('Settings > Sidebar > General > Edit', () => {
});
// * Verify that new username is in profile popover
cy.get('#user-profile-popover').within(() => {
cy.get('div.user-profile-popover').within(() => {
cy.get('#userPopoverUsername').should('be.visible').and('contain', `${otherUser.username}-${randomId}`);
});
});

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

@@ -67,7 +67,7 @@ describe('Bot display name', () => {
cy.get('@botPost').then((postIdA) => {
cy.get(`#post_${postIdA} button.user-popover`).click();
cy.get('#user-profile-popover').
cy.get('div.user-profile-popover').
should('be.visible');
cy.findByTestId(`popover-fullname-${bot.username}`).
@@ -83,7 +83,7 @@ describe('Bot display name', () => {
// * Verify changed display name
cy.get('@newBotPost').then(() => {
cy.get('#user-profile-popover').
cy.get('div.user-profile-popover').
should('be.visible');
cy.findByTestId(`popover-fullname-${bot.username}`).

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

@@ -356,10 +356,11 @@ function verifyMentionedUserAndProfilePopover(postId: string) {
cy.wrap($el).click();
// * Profile popover should be visible
cy.get('#user-profile-popover').should('be.visible');
cy.get('div.user-profile-popover').should('be.visible');
// * The username in the popover the same as the username link for each user
cy.get('#userPopoverUsername').should('contain', userName);
cy.get('div.user-profile-popover').should('contain', userName);
cy.get('button.closeButtonRelativePosition').click();
// Click anywhere to close profile popover
cy.get('#channelHeaderInfo').click();

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

@@ -117,9 +117,9 @@ describe('MM-T4064 Status expiry visibility', () => {
it('MM-T4064_7 should show custom status expiry time in the user popover', () => {
// # Click on the post header of the last post by the current user and open profile popover
cy.get('.post.current--user .post__header .user-popover').first().click();
cy.get('#user-profile-popover').should('exist');
cy.get('div.user-profile-popover').should('exist');
// * Check if the profile popover contains custom status expiry time in the Status heading
cy.get('#user-profile-popover #user-popover-status .user-popover__subtitle time').should('have.text', expiresAt.format(expiryTimeFormat));
cy.get('div.user-profile-popover #user-popover-status .user-popover__subtitle time').should('have.text', expiresAt.format(expiryTimeFormat));
});
});

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

@@ -108,9 +108,10 @@ describe('Guest Account - Guest User Experience', () => {
});
// * Verify Guest Badge in Guest User's Profile Popover
cy.get('#user-profile-popover').should('be.visible').within(($el) => {
cy.get('div.user-profile-popover').should('be.visible').within(($el) => {
cy.wrap($el).find('.GuestTag').should('be.visible').and('have.text', 'GUEST');
});
cy.get('button.closeButtonRelativePosition').click();
// # Close the profile popover
cy.get('#channel-header').click();
@@ -168,9 +169,10 @@ describe('Guest Account - Guest User Experience', () => {
});
// * Verify Guest Badge is not displayed in User's Profile Popover
cy.get('#user-profile-popover').should('be.visible').within(($el) => {
cy.get('div.user-profile-popover').should('be.visible').within(($el) => {
cy.wrap($el).find('.user-popover__role').should('not.exist');
});
cy.get('button.closeButtonRelativePosition').click();
// # Close the profile popover
cy.get('#channel-header').click();

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

@@ -106,9 +106,10 @@ describe('Verify Guest User Identification in different screens', () => {
});
// * Verify Guest Badge in Guest User's Profile Popover
cy.get('#user-profile-popover').should('be.visible').within(($el) => {
cy.get('div.user-profile-popover').should('be.visible').within(($el) => {
cy.wrap($el).find('.GuestTag').should('be.visible').and('have.text', 'GUEST');
});
cy.get('button.closeButtonRelativePosition').click();
// # Close the profile popover
cy.get('#channel-header').click();

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

@@ -76,7 +76,7 @@ describe('Profile popover User A & B', () => {
find(`[data-mention=${otherUser.username}]`).
should('be.visible').
click();
cy.get('#user-profile-popover').should('be.visible');
cy.get('div.user-profile-popover').should('be.visible');
});
// # Add to a Channel should not be shown.

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

@@ -160,7 +160,7 @@ function verifyLastPost(owner, username, iconUrl) {
function verifyProfilePopover(owner, username, iconUrl) {
// * Verify that the profile popover is shown
cy.get('#user-profile-popover').should('be.visible').within(() => {
cy.get('div.user-profile-popover').should('be.visible').within(() => {
// * Verify username from payload
cy.get('.user-profile-popover__heading').should('be.visible').and('have.text', username);

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

@@ -37,7 +37,7 @@ describe('Profile popover', () => {
cy.get(`#post_${postId}`).find('.profile-icon > img').click({force: true});
// * Popover should have rendered to screen
cy.get('#user-profile-popover').should('be.visible');
cy.get('div.user-profile-popover').should('be.visible');
cy.get('body').type('{esc}');
});
});
@@ -50,7 +50,7 @@ describe('Profile popover', () => {
cy.get(`#post_${postId}`).find('.user-popover').click({force: true});
// * Popover should have rendered to screen
cy.get('#user-profile-popover').should('be.visible');
cy.get('div.user-profile-popover').should('be.visible');
});
});
});

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

@@ -60,7 +60,7 @@ describe('Scroll', () => {
cy.getLastPostId().as('lastPostId');
// Getting height of each post before applying 'Fixed width, centered' option and assigning to alias
cy.findAllByLabelText('sysadmin').eq(0).invoke('height').then((height) => {
cy.get('button.user-popover.style--none').eq(0).invoke('height').then((height) => {
cy.wrap(height).as('initialUserNameHeight');
});
getComponentByText('@firstPostId', firstMessage).invoke('height').then((height) => {
@@ -72,10 +72,10 @@ describe('Scroll', () => {
getFileThumbnail('mpeg-video-file.mpg').invoke('height').then((height) => {
cy.wrap(height).as('initialMpgHeight');
});
getFileThumbnail('gif-image-file.gif').invoke('height').then((height) => {
getImageThumbnail('gif-image-file.gif').invoke('height').then((height) => {
cy.wrap(height).as('initialGifHeight');
});
getFileThumbnail('jpg-image-file.jpg').invoke('height').then((height) => {
getImageThumbnail('jpg-image-file.jpg').invoke('height').then((height) => {
cy.wrap(height).as('initialJpgHeight');
});
getComponentBySelector('@linkPreviewPostId', '.PostAttachmentOpenGraph__image').invoke('height').then((height) => {
@@ -105,7 +105,7 @@ describe('Scroll', () => {
// * Verify there is no scroll pop
cy.get('#post-list').should('exist').within(() => {
cy.get('@initialUserNameHeight').then((originalHeight) => {
cy.findAllByLabelText('sysadmin').eq(0).invoke('height').should('be.equal', originalHeight);
cy.get('button.user-popover.style--none').eq(0).invoke('height').should('be.equal', originalHeight);
});
cy.get('@initialFirstPostHeight').then((originalHeight) => {
getComponentByText('@firstPostId', firstMessage).invoke('height').should('be.equal', originalHeight);
@@ -120,10 +120,10 @@ describe('Scroll', () => {
getFileThumbnail('mpeg-video-file.mpg').invoke('height').should('be.equal', originalHeight);
});
cy.get('@initialGifHeight').then((originalHeight) => {
getFileThumbnail('gif-image-file.gif').invoke('height').should('be.equal', originalHeight);
getImageThumbnail('gif-image-file.gif').invoke('height').should('be.equal', originalHeight);
});
cy.get('@initialJpgHeight').then((originalHeight) => {
getFileThumbnail('jpg-image-file.jpg').invoke('height').should('be.equal', originalHeight);
getImageThumbnail('jpg-image-file.jpg').invoke('height').should('be.equal', originalHeight);
});
cy.get('@initialInlineImgHeight').then((originalHeight) => {
getComponentBySelector('@gifLinkPostId', 'img[aria-label="file thumbnail"]').invoke('height').should('be.equal', originalHeight);
@@ -136,6 +136,13 @@ describe('Scroll', () => {
// Get thumbnail component based on filename
const getFileThumbnail = (filename) => {
return cy.get(`@${filename}PostId`).then((postId) => {
cy.get(`#${postId}_message a.post-image__name`);
});
};
// Get image component based on filename
const getImageThumbnail = (filename) => {
return cy.get(`@${filename}PostId`).then((postId) => {
cy.get(`#${postId}_message`).findByLabelText(`file thumbnail ${filename}`);
});

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

@@ -9,7 +9,9 @@ exports[`components/admin_console/admin_user_card/admin_user_card should match d
class="AdminUserCard__header"
>
<span
class="status-wrapper admin-user-card"
aria-expanded="false"
aria-haspopup="dialog"
class="status-wrapper admin-user-card"
>
<button
class="RoundButton-dvlhqG gnYSKj style--none"
@@ -22,7 +24,6 @@ exports[`components/admin_console/admin_user_card/admin_user_card should match d
class="Avatar Avatar-xxl"
loading="lazy"
src="/api/v4/users/1234/image"
tabindex="-1"
/>
</span>
</button>
@@ -69,7 +70,9 @@ exports[`components/admin_console/admin_user_card/admin_user_card should match s
class="AdminUserCard__header"
>
<span
class="status-wrapper admin-user-card"
aria-expanded="false"
aria-haspopup="dialog"
class="status-wrapper admin-user-card"
>
<button
class="RoundButton-dvlhqG gnYSKj style--none"
@@ -82,7 +85,6 @@ exports[`components/admin_console/admin_user_card/admin_user_card should match s
class="Avatar Avatar-xxl"
loading="lazy"
src="/api/v4/users/1234/image"
tabindex="-1"
/>
</span>
</button>
@@ -124,7 +126,9 @@ exports[`components/admin_console/admin_user_card/admin_user_card should match s
class="AdminUserCard__header"
>
<span
class="status-wrapper admin-user-card"
aria-expanded="false"
aria-haspopup="dialog"
class="status-wrapper admin-user-card"
>
<button
class="RoundButton-dvlhqG gnYSKj style--none"
@@ -137,7 +141,6 @@ exports[`components/admin_console/admin_user_card/admin_user_card should match s
class="Avatar Avatar-xxl"
loading="lazy"
src="/api/v4/users/1234/image"
tabindex="-1"
/>
</span>
</button>
@@ -177,7 +180,9 @@ exports[`components/admin_console/admin_user_card/admin_user_card should match s
class="AdminUserCard__header"
>
<span
class="status-wrapper admin-user-card"
aria-expanded="false"
aria-haspopup="dialog"
class="status-wrapper admin-user-card"
>
<button
class="RoundButton-dvlhqG gnYSKj style--none"
@@ -190,7 +195,6 @@ exports[`components/admin_console/admin_user_card/admin_user_card should match s
class="Avatar Avatar-xxl"
loading="lazy"
src="/api/v4/users/1234/image"
tabindex="-1"
/>
</span>
</button>

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

@@ -2,114 +2,68 @@
exports[`components/AtMention should match snapshot when mentioning a group followed by punctuation 1`] = `
<Fragment>
<span>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<Connect(Component)
group={
Object {
"allow_reference": true,
"create_at": 1,
"delete_at": 0,
"description": "",
"display_name": "group_display_name",
"has_syncables": false,
"id": "qwerty1",
"member_count": 0,
"name": "developers",
"remote_id": "",
"scheme_admin": false,
"source": "",
"update_at": 1,
}
}
hide={[Function]}
returnFocus={[Function]}
showUserOverlay={[Function]}
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<UserGroupPopoverController
group={
Object {
"allow_reference": true,
"create_at": 1,
"delete_at": 0,
"description": "",
"display_name": "group_display_name",
"has_syncables": false,
"id": "qwerty1",
"member_count": 0,
"name": "developers",
"remote_id": "",
"scheme_admin": false,
"source": "",
"update_at": 1,
}
}
returnFocus={[Function]}
>
<a
aria-haspopup="dialog"
className="group-mention-link"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
@developers
</a>
</span>
</UserGroupPopoverController>
.
</Fragment>
`;
exports[`components/AtMention should match snapshot when mentioning a group that is allowed reference 1`] = `
<Fragment>
<span>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<Connect(Component)
group={
Object {
"allow_reference": true,
"create_at": 1,
"delete_at": 0,
"description": "",
"display_name": "group_display_name",
"has_syncables": false,
"id": "qwerty1",
"member_count": 0,
"name": "developers",
"remote_id": "",
"scheme_admin": false,
"source": "",
"update_at": 1,
}
}
hide={[Function]}
returnFocus={[Function]}
showUserOverlay={[Function]}
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<UserGroupPopoverController
group={
Object {
"allow_reference": true,
"create_at": 1,
"delete_at": 0,
"description": "",
"display_name": "group_display_name",
"has_syncables": false,
"id": "qwerty1",
"member_count": 0,
"name": "developers",
"remote_id": "",
"scheme_admin": false,
"source": "",
"update_at": 1,
}
}
returnFocus={[Function]}
>
<a
aria-haspopup="dialog"
className="group-mention-link"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
@developers
</a>
</span>
</UserGroupPopoverController>
</Fragment>
`;
@@ -139,285 +93,136 @@ exports[`components/AtMention should match snapshot when mentioning all with mix
exports[`components/AtMention should match snapshot when mentioning current user 1`] = `
<Fragment>
<span
className="mention--highlight"
<ProfilePopoverController
returnFocus={[Function]}
src="/api/v4/users/abc1/image"
triggerComponentClass="mention--highlight"
userId="abc1"
>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/abc1/image"
userId="abc1"
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<a
aria-haspopup="dialog"
className="mention-link"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
@First Last
</a>
</span>
</ProfilePopoverController>
</Fragment>
`;
exports[`components/AtMention should match snapshot when mentioning user 1`] = `
<Fragment>
<span>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/abc2/image"
userId="abc2"
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<ProfilePopoverController
returnFocus={[Function]}
src="/api/v4/users/abc2/image"
triggerComponentClass=""
userId="abc2"
>
<a
aria-haspopup="dialog"
className="mention-link"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
@Nick
</a>
</span>
</ProfilePopoverController>
</Fragment>
`;
exports[`components/AtMention should match snapshot when mentioning user containing and followed by punctuation 1`] = `
<Fragment>
<span>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/abc3/image"
userId="abc3"
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<ProfilePopoverController
returnFocus={[Function]}
src="/api/v4/users/abc3/image"
triggerComponentClass=""
userId="abc3"
>
<a
aria-haspopup="dialog"
className="mention-link"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
@Dot Matrix
</a>
</span>
</ProfilePopoverController>
.
</Fragment>
`;
exports[`components/AtMention should match snapshot when mentioning user containing punctuation 1`] = `
<Fragment>
<span>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/abc3/image"
userId="abc3"
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<ProfilePopoverController
returnFocus={[Function]}
src="/api/v4/users/abc3/image"
triggerComponentClass=""
userId="abc3"
>
<a
aria-haspopup="dialog"
className="mention-link"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
@Dot Matrix
</a>
</span>
</ProfilePopoverController>
</Fragment>
`;
exports[`components/AtMention should match snapshot when mentioning user followed by punctuation 1`] = `
<Fragment>
<span>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/abc2/image"
userId="abc2"
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<ProfilePopoverController
returnFocus={[Function]}
src="/api/v4/users/abc2/image"
triggerComponentClass=""
userId="abc2"
>
<a
aria-haspopup="dialog"
className="mention-link"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
@Nick
</a>
</span>
</ProfilePopoverController>
...
</Fragment>
`;
exports[`components/AtMention should match snapshot when mentioning user with different teammate name display setting 1`] = `
<Fragment>
<span>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/abc2/image"
userId="abc2"
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<ProfilePopoverController
returnFocus={[Function]}
src="/api/v4/users/abc2/image"
triggerComponentClass=""
userId="abc2"
>
<a
aria-haspopup="dialog"
className="mention-link"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
@user1
</a>
</span>
</ProfilePopoverController>
</Fragment>
`;
exports[`components/AtMention should match snapshot when mentioning user with mixed case 1`] = `
<Fragment>
<span>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/abc2/image"
userId="abc2"
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<ProfilePopoverController
returnFocus={[Function]}
src="/api/v4/users/abc2/image"
triggerComponentClass=""
userId="abc2"
>
<a
aria-haspopup="dialog"
className="mention-link"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
@Nick
</a>
</span>
</ProfilePopoverController>
</Fragment>
`;

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

@@ -28,6 +28,7 @@ describe('components/AtMention', () => {
marketing: TestHelper.getGroupMock({id: 'qwerty2', name: 'marketing', allow_reference: false}),
accounting: TestHelper.getGroupMock({id: 'qwerty3', name: 'accounting', allow_reference: true}),
},
dispatch: jest.fn(),
};
test('should match snapshot when mentioning user', () => {

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

@@ -1,108 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef, useState, useMemo, type ComponentProps} from 'react';
import {Overlay} from 'react-bootstrap';
import type {Group} from '@mattermost/types/groups';
import type {UserProfile} from '@mattermost/types/users';
import classNames from 'classnames';
import React, {useRef, useMemo, memo} from 'react';
import {Client4} from 'mattermost-redux/client';
import {displayUsername} from 'mattermost-redux/utils/user_utils';
import ProfilePopover from 'components/profile_popover';
import UserGroupPopover from 'components/user_group_popover';
import {MAX_LIST_HEIGHT, getListHeight, VIEWPORT_SCALE_FACTOR} from 'components/user_group_popover/group_member_list/group_member_list';
import type {A11yFocusEventDetail} from 'utils/constants';
import Constants, {A11yCustomEventTypes} from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import {popOverOverlayPosition, approxGroupPopOverHeight} from 'utils/position_utils';
import {A11yCustomEventTypes} from 'utils/constants';
import {getUserOrGroupFromMentionName} from 'utils/post_utils';
import {getViewportSize} from 'utils/utils';
const HEADER_HEIGHT_ESTIMATE = 130;
import type {PropsFromRedux} from './index';
type Props = {
currentUserId: string;
type OwnProps = {
mentionName: string;
teammateNameDisplay: string;
usersByUsername: Record<string, UserProfile>;
groupsByName: Record<string, Group>;
children?: React.ReactNode;
channelId?: string;
disableHighlight?: boolean;
disableGroupHighlight?: boolean;
}
export const AtMention = (props: Props) => {
const ref = useRef<HTMLAnchorElement>(null);
type Props = OwnProps & PropsFromRedux;
const [show, setShow] = useState(false);
const [groupUser, setGroupUser] = useState<UserProfile | undefined>();
const [target, setTarget] = useState<HTMLAnchorElement | undefined>();
const [placement, setPlacement] = useState<ComponentProps<typeof Overlay>['placement']>('right');
const AtMention = (props: Props) => {
const ref = useRef<HTMLAnchorElement>(null);
const [user, group] = useMemo(
() => getUserOrGroupFromMentionName(props.mentionName, props.usersByUsername, props.groupsByName, props.disableGroupHighlight),
[props.mentionName, props.usersByUsername, props.groupsByName, props.disableGroupHighlight],
);
const showOverlay = (target?: HTMLAnchorElement) => {
const targetBounds = ref.current?.getBoundingClientRect();
if (targetBounds) {
let popOverHeight: number;
if (group) {
popOverHeight = approxGroupPopOverHeight(
getListHeight(group.member_count),
getViewportSize().h,
VIEWPORT_SCALE_FACTOR,
HEADER_HEIGHT_ESTIMATE,
MAX_LIST_HEIGHT,
);
} else {
popOverHeight = getViewportSize().h - 240;
}
const placement = popOverOverlayPosition(targetBounds, getViewportSize().h, popOverHeight);
setTarget(target);
setShow(!show);
setGroupUser(undefined);
setPlacement(placement);
}
};
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
showOverlay(e.target as HTMLAnchorElement);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLAnchorElement>) => {
if (isKeyPressed(e, Constants.KeyCodes.ENTER) || isKeyPressed(e, Constants.KeyCodes.SPACE)) {
e.preventDefault();
// Prevent propagation so that the message textbox isn't focused
e.stopPropagation();
showOverlay(e.target as HTMLAnchorElement);
}
};
const hideOverlay = () => {
setShow(false);
};
const showGroupUserOverlay = (user: UserProfile) => {
hideOverlay();
setGroupUser(user);
};
const hideGroupUserOverlay = () => {
setGroupUser(undefined);
};
const returnFocus = () => {
document.dispatchEvent(new CustomEvent<A11yFocusEventDetail>(
A11yCustomEventTypes.FOCUS, {
@@ -114,98 +45,57 @@ export const AtMention = (props: Props) => {
));
};
const getPopOver = (user?: UserProfile, group?: Group) => {
if (user) {
return (
if (user) {
const userMentionNameSuffix = props.mentionName.substring(user.username.length);
const userDisplayName = displayUsername(user, props.teammateNameDisplay);
const highlightMention = !props.disableHighlight && user.id === props.currentUserId;
return (
<>
<ProfilePopover
className='user-profile-popover'
triggerComponentClass={classNames({'mention--highlight': highlightMention})}
userId={user.id}
src={Client4.getProfilePictureUrl(user.id, user.last_picture_update)}
hide={hideOverlay}
channelId={props.channelId}
/>
);
}
returnFocus={returnFocus}
>
<a
ref={ref}
className='mention-link'
role='button'
tabIndex={0}
>
{'@' + userDisplayName}
</a>
</ProfilePopover>
{userMentionNameSuffix}
</>
);
} else if (group) {
const groupMentionNameSuffix = props.mentionName.substring(group.name.length);
const groupDisplayName = group.name;
if (group) {
return (
return (
<>
<UserGroupPopover
group={group}
hide={hideOverlay}
showUserOverlay={showGroupUserOverlay}
returnFocus={returnFocus}
/>
);
}
return null;
};
if (!user && !group) {
return <>{props.children}</>;
>
<a
ref={ref}
className='group-mention-link'
role='button'
tabIndex={0}
>
{'@' + groupDisplayName}
</a>
</UserGroupPopover>
{groupMentionNameSuffix}
</>
);
}
let suffix = '';
let displayName = '';
let highlightMention = false; // only for user
if (user) {
suffix = props.mentionName.substring(user.username.length);
displayName = displayUsername(user, props.teammateNameDisplay);
highlightMention = !props.disableHighlight && user.id === props.currentUserId;
} else if (group) { // if statement needed for union
suffix = props.mentionName.substring(group.name.length);
displayName = group.name;
}
return (
<>
<span
className={highlightMention ? 'mention--highlight' : undefined}
>
<Overlay
placement={placement}
show={show}
target={target}
rootClose={true}
onHide={hideOverlay}
>
{getPopOver(user, group)}
</Overlay>
<Overlay
placement={placement}
show={groupUser !== undefined}
target={target}
onHide={hideGroupUserOverlay}
rootClose={true}
>
{groupUser ? (
<ProfilePopover
className='user-profile-popover'
userId={groupUser.id}
src={Client4.getProfilePictureUrl(groupUser.id, groupUser.last_picture_update)}
channelId={props.channelId}
hide={hideGroupUserOverlay}
returnFocus={returnFocus}
/>
) : <span/> // prevents blank-screen crash when closing groupUser ProfilePopover
}
</Overlay>
<a
onClick={handleClick}
onKeyDown={handleKeyDown}
className={group ? 'group-mention-link' : 'mention-link'}
ref={ref}
aria-haspopup='dialog'
role='button'
tabIndex={0}
>
{'@' + displayName}
</a>
</span>
{suffix}
</>
);
return <>{props.children}</>;
};
export default React.memo(AtMention);
export default memo(AtMention);

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

@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ConnectedProps} from 'react-redux';
import {connect} from 'react-redux';
import {getAllGroupsForReferenceByName} from 'mattermost-redux/selectors/entities/groups';
@@ -20,4 +21,7 @@ function mapStateToProps(state: GlobalState) {
};
}
export default connect(mapStateToProps)(AtMention);
const connector = connect(mapStateToProps);
export type PropsFromRedux = ConnectedProps<typeof connector>;
export default connector(AtMention);

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

@@ -99,7 +99,6 @@ const AboutAreaDM = ({channel, dmUser, actions}: Props) => {
userId={dmUser.user.id}
channelId={channel.id}
size='xl'
popoverPlacement='left'
/>
</UserAvatar>
<UserInfo>

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

@@ -159,7 +159,7 @@ describe('channel_info_rhs/about_area_gm', () => {
initialState,
);
expect(screen.getByLabelText('my username')).toBeInTheDocument();
expect(screen.getByText('my username')).toBeInTheDocument();
});
test('should display channel header', () => {

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

@@ -89,7 +89,6 @@ const AboutAreaGM = ({channel, gmUsers, actions}: Props) => {
userId={user.id}
username={user.username}
channelId={channel.id}
popoverPlacement='left'
/>
</ProfilePictureContainer>
))}
@@ -99,7 +98,6 @@ const AboutAreaGM = ({channel, gmUsers, actions}: Props) => {
<React.Fragment key={user.id}>
<UserProfileElement
userId={user.id}
isRHS={true}
channelId={channel.id}
/>
{(i + 1 !== length) && (<span>{', '}</span>)}

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

@@ -3,7 +3,7 @@
import classNames from 'classnames';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {FormattedMessage, useIntl} from 'react-intl';
import styled from 'styled-components';
import type {Channel} from '@mattermost/types/channels';
@@ -14,14 +14,10 @@ import {isGuest} from 'mattermost-redux/utils/user_utils';
import ChannelMembersDropdown from 'components/channel_members_dropdown';
import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
import OverlayTrigger from 'components/overlay_trigger';
import type {BaseOverlayTrigger} from 'components/overlay_trigger';
import ProfilePicture from 'components/profile_picture';
import ProfilePopover from 'components/profile_popover';
import Tooltip from 'components/tooltip';
import GuestTag from 'components/widgets/tag/guest_tag';
import Constants from 'utils/constants';
import WithTooltip from 'components/with_tooltip';
import type {ChannelMember} from './channel_members_rhs';
@@ -30,15 +26,6 @@ const Avatar = styled.div`
flex-shrink: 0;
`;
const UserInfo = styled.div`
display: flex;
flex: 1;
cursor: pointer;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const DisplayName = styled.span`
display: inline;
overflow: hidden;
@@ -110,19 +97,10 @@ interface Props {
};
}
interface MMOverlayTrigger extends BaseOverlayTrigger {
hide: () => void;
}
const Member = ({className, channel, member, index, totalUsers, editing, actions}: Props) => {
const overlay = React.createRef<MMOverlayTrigger>();
const profileSrc = Client4.getProfilePictureUrl(member.user.id, member.user.last_picture_update);
const {formatMessage} = useIntl();
const hideProfilePopover = () => {
if (overlay.current) {
overlay.current.hide();
}
};
const userProfileSrc = Client4.getProfilePictureUrl(member.user.id, member.user.last_picture_update);
return (
<div
@@ -130,59 +108,46 @@ const Member = ({className, channel, member, index, totalUsers, editing, actions
style={{height: '48px'}}
data-testid={`memberline-${member.user.id}`}
>
<OverlayTrigger
ref={overlay}
trigger={['click']}
placement={'left'}
rootClose={true}
overlay={
<ProfilePopover
className='user-profile-popover'
<span className='ProfileSpan'>
<Avatar>
<ProfilePicture
size='sm'
status={member.status}
isBot={member.user.is_bot}
userId={member.user.id}
src={profileSrc}
hide={hideProfilePopover}
hideStatus={member.user.is_bot}
username={member.displayName}
src={userProfileSrc}
/>
}
>
<span className='ProfileSpan'>
<Avatar>
<ProfilePicture
popoverPlacement='left'
size='sm'
status={member.status}
isBot={member.user.is_bot}
userId={member.user.id}
username={member.displayName}
src={Client4.getProfilePictureUrl(member.user.id, member.user.last_picture_update)}
/>
</Avatar>
<UserInfo>
<DisplayName>
{member.displayName}
{isGuest(member.user.roles) && <GuestTag/>}
</DisplayName>
{
member.displayName === member.user.username ? null : <Username>{'@'}{member.user.username}</Username>
}
<CustomStatusEmoji
userID={member.user.id}
showTooltip={true}
emojiSize={16}
spanStyle={{
display: 'flex',
flex: '0 0 auto',
alignItems: 'center',
}}
emojiStyle={{
marginLeft: '8px',
alignItems: 'center',
}}
/>
</UserInfo>
</span>
</OverlayTrigger>
</Avatar>
<ProfilePopover
triggerComponentClass='profileSpan_userInfo'
userId={member.user.id}
src={userProfileSrc}
hideStatus={member.user.is_bot}
>
<DisplayName>
{member.displayName}
{isGuest(member.user.roles) && <GuestTag/>}
</DisplayName>
{
member.displayName === member.user.username ? null : <Username>{'@'}{member.user.username}</Username>
}
<CustomStatusEmoji
userID={member.user.id}
showTooltip={true}
emojiSize={16}
spanStyle={{
display: 'flex',
flex: '0 0 auto',
alignItems: 'center',
}}
emojiStyle={{
marginLeft: '8px',
alignItems: 'center',
}}
/>
</ProfilePopover>
</span>
<RoleChooser
className={classNames({editing}, 'member-role-chooser')}
@@ -217,22 +182,18 @@ const Member = ({className, channel, member, index, totalUsers, editing, actions
)}
</RoleChooser>
{!editing && (
<SendMessage onClick={() => actions.openDirectMessage(member.user)}>
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
placement='left'
overlay={
<Tooltip>
<FormattedMessage
id='channel_members_rhs.member.send_message'
defaultMessage='Send message'
/>
</Tooltip>
}
>
<WithTooltip
id={`member-tooltip-${member.user.id}`}
title={formatMessage({
id: 'channel_members_rhs.member.send_message',
defaultMessage: 'Send message',
})}
placement='left'
>
<SendMessage onClick={() => actions.openDirectMessage(member.user)}>
<i className='icon icon-send'/>
</OverlayTrigger>
</SendMessage>
</SendMessage>
</WithTooltip>
)}
</div>
);
@@ -256,14 +217,20 @@ export default styled(Member)`
}
.ProfileSpan {
display: flex;
overflow: hidden;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
// This padding is to make sure the status icon doesnt get clipped off because of the overflow
padding: 4px 0;
margin-right: auto;
padding: 4px 0; // This padding is to make sure the status icon doesn't get clipped off because of the overflow
.profileSpan_userInfo {
display: flex;
flex-grow: 1;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.MenuWrapper {

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

@@ -31,161 +31,89 @@ exports[`components/drafts/panel/panel_body should have called handleFormattedTe
>
<ProfilePicture
channelId="channel_id"
hasMention={false}
isEmoji={false}
popoverPlacement="right"
size="md"
src="/api/v4/users/user_id/image?_=0"
status="status"
userId="user_id"
username="username"
wrapperClass=""
>
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(ProfilePopover)
channelId="channel_id"
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/user_id/image?_=0"
userId="user_id"
/>
}
placement="right"
rootClose={true}
trigger={
Array [
"click",
]
}
<ProfilePopoverController
channelId="channel_id"
src="/api/v4/users/user_id/image?_=0"
triggerComponentClass="status-wrapper"
userId="user_id"
>
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<OverlayWrapper
channelId="channel_id"
className="user-profile-popover"
hide={[Function]}
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
"wrapRichTextChunksInFragment": undefined,
}
}
src="/api/v4/users/user_id/image?_=0"
userId="user_id"
/>
}
placement="right"
rootClose={true}
trigger={
Array [
"click",
]
}
<span
aria-expanded="false"
aria-haspopup="dialog"
className="status-wrapper"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onPointerDown={[Function]}
>
<span
className="status-wrapper "
onClick={[Function]}
<RoundButton
className="style--none"
size="md"
>
<RoundButton
className="style--none"
<button
className="RoundButton-dvlhqG gfRzmz style--none"
size="md"
>
<button
className="RoundButton-dvlhqG gfRzmz style--none"
size="md"
<span
className="profile-icon "
>
<span
className="profile-icon "
<Memo(Avatar)
size="md"
url="/api/v4/users/user_id/image?_=0"
username="username"
>
<Memo(Avatar)
size="md"
tabIndex={-1}
url="/api/v4/users/user_id/image?_=0"
username="username"
>
<img
alt="username profile image"
className="Avatar Avatar-md"
loading="lazy"
onError={[Function]}
src="/api/v4/users/user_id/image?_=0"
tabIndex={-1}
/>
</Memo(Avatar)>
</span>
</button>
</RoundButton>
<Memo(StatusIcon)
status="status"
<img
alt="username profile image"
className="Avatar Avatar-md"
loading="lazy"
onError={[Function]}
src="/api/v4/users/user_id/image?_=0"
/>
</Memo(Avatar)>
</span>
</button>
</RoundButton>
<Memo(StatusIcon)
status="status"
>
<StatusOfflineIcon
className="status "
>
<StatusOfflineIcon
<span
className="status "
>
<span
className="status "
>
<svg
aria-label="Offline Icon"
className="offline--icon"
height="100%"
role="img"
style={
Object {
"clipRule": "evenodd",
"fillRule": "evenodd",
"strokeLinejoin": "round",
"strokeMiterlimit": 1.41421,
}
<svg
aria-label="Offline Icon"
className="offline--icon"
height="100%"
role="img"
style={
Object {
"clipRule": "evenodd",
"fillRule": "evenodd",
"strokeLinejoin": "round",
"strokeMiterlimit": 1.41421,
}
viewBox="0 0 20 20"
width="100%"
>
<path
d="M10,0c5.519,0 10,4.481 10,10c0,5.519 -4.481,10 -10,10c-5.519,0 -10,-4.481 -10,-10c0,-5.519 4.481,-10 10,-10Zm0,2c4.415,0 8,3.585 8,8c0,4.415 -3.585,8 -8,8c-4.415,0 -8,-3.585 -8,-8c0,-4.415 3.585,-8 8,-8Z"
/>
</svg>
</span>
</StatusOfflineIcon>
</Memo(StatusIcon)>
</span>
</OverlayTrigger>
</OverlayTrigger>
}
viewBox="0 0 20 20"
width="100%"
>
<path
d="M10,0c5.519,0 10,4.481 10,10c0,5.519 -4.481,10 -10,10c-5.519,0 -10,-4.481 -10,-10c0,-5.519 4.481,-10 10,-10Zm0,2c4.415,0 8,3.585 8,8c0,4.415 -3.585,8 -8,8c-4.415,0 -8,-3.585 -8,-8c0,-4.415 3.585,-8 8,-8Z"
/>
</svg>
</span>
</StatusOfflineIcon>
</Memo(StatusIcon)>
</span>
</ProfilePopoverController>
</ProfilePicture>
</div>
<div
@@ -300,161 +228,89 @@ exports[`components/drafts/panel/panel_body should match snapshot 1`] = `
>
<ProfilePicture
channelId="channel_id"
hasMention={false}
isEmoji={false}
popoverPlacement="right"
size="md"
src="/api/v4/users/user_id/image?_=0"
status="status"
userId="user_id"
username="username"
wrapperClass=""
>
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(ProfilePopover)
channelId="channel_id"
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/user_id/image?_=0"
userId="user_id"
/>
}
placement="right"
rootClose={true}
trigger={
Array [
"click",
]
}
<ProfilePopoverController
channelId="channel_id"
src="/api/v4/users/user_id/image?_=0"
triggerComponentClass="status-wrapper"
userId="user_id"
>
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<OverlayWrapper
channelId="channel_id"
className="user-profile-popover"
hide={[Function]}
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
"wrapRichTextChunksInFragment": undefined,
}
}
src="/api/v4/users/user_id/image?_=0"
userId="user_id"
/>
}
placement="right"
rootClose={true}
trigger={
Array [
"click",
]
}
<span
aria-expanded="false"
aria-haspopup="dialog"
className="status-wrapper"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onPointerDown={[Function]}
>
<span
className="status-wrapper "
onClick={[Function]}
<RoundButton
className="style--none"
size="md"
>
<RoundButton
className="style--none"
<button
className="RoundButton-dvlhqG gfRzmz style--none"
size="md"
>
<button
className="RoundButton-dvlhqG gfRzmz style--none"
size="md"
<span
className="profile-icon "
>
<span
className="profile-icon "
<Memo(Avatar)
size="md"
url="/api/v4/users/user_id/image?_=0"
username="username"
>
<Memo(Avatar)
size="md"
tabIndex={-1}
url="/api/v4/users/user_id/image?_=0"
username="username"
>
<img
alt="username profile image"
className="Avatar Avatar-md"
loading="lazy"
onError={[Function]}
src="/api/v4/users/user_id/image?_=0"
tabIndex={-1}
/>
</Memo(Avatar)>
</span>
</button>
</RoundButton>
<Memo(StatusIcon)
status="status"
<img
alt="username profile image"
className="Avatar Avatar-md"
loading="lazy"
onError={[Function]}
src="/api/v4/users/user_id/image?_=0"
/>
</Memo(Avatar)>
</span>
</button>
</RoundButton>
<Memo(StatusIcon)
status="status"
>
<StatusOfflineIcon
className="status "
>
<StatusOfflineIcon
<span
className="status "
>
<span
className="status "
>
<svg
aria-label="Offline Icon"
className="offline--icon"
height="100%"
role="img"
style={
Object {
"clipRule": "evenodd",
"fillRule": "evenodd",
"strokeLinejoin": "round",
"strokeMiterlimit": 1.41421,
}
<svg
aria-label="Offline Icon"
className="offline--icon"
height="100%"
role="img"
style={
Object {
"clipRule": "evenodd",
"fillRule": "evenodd",
"strokeLinejoin": "round",
"strokeMiterlimit": 1.41421,
}
viewBox="0 0 20 20"
width="100%"
>
<path
d="M10,0c5.519,0 10,4.481 10,10c0,5.519 -4.481,10 -10,10c-5.519,0 -10,-4.481 -10,-10c0,-5.519 4.481,-10 10,-10Zm0,2c4.415,0 8,3.585 8,8c0,4.415 -3.585,8 -8,8c-4.415,0 -8,-3.585 -8,-8c0,-4.415 3.585,-8 8,-8Z"
/>
</svg>
</span>
</StatusOfflineIcon>
</Memo(StatusIcon)>
</span>
</OverlayTrigger>
</OverlayTrigger>
}
viewBox="0 0 20 20"
width="100%"
>
<path
d="M10,0c5.519,0 10,4.481 10,10c0,5.519 -4.481,10 -10,10c-5.519,0 -10,-4.481 -10,-10c0,-5.519 4.481,-10 10,-10Zm0,2c4.415,0 8,3.585 8,8c0,4.415 -3.585,8 -8,8c-4.415,0 -8,-3.585 -8,-8c0,-4.415 3.585,-8 8,-8Z"
/>
</svg>
</span>
</StatusOfflineIcon>
</Memo(StatusIcon)>
</span>
</ProfilePopoverController>
</ProfilePicture>
</div>
<div
@@ -575,161 +431,89 @@ exports[`components/drafts/panel/panel_body should match snapshot for priority 1
>
<ProfilePicture
channelId="channel_id"
hasMention={false}
isEmoji={false}
popoverPlacement="right"
size="md"
src="/api/v4/users/user_id/image?_=0"
status="status"
userId="user_id"
username="username"
wrapperClass=""
>
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(ProfilePopover)
channelId="channel_id"
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/user_id/image?_=0"
userId="user_id"
/>
}
placement="right"
rootClose={true}
trigger={
Array [
"click",
]
}
<ProfilePopoverController
channelId="channel_id"
src="/api/v4/users/user_id/image?_=0"
triggerComponentClass="status-wrapper"
userId="user_id"
>
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<OverlayWrapper
channelId="channel_id"
className="user-profile-popover"
hide={[Function]}
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
"wrapRichTextChunksInFragment": undefined,
}
}
src="/api/v4/users/user_id/image?_=0"
userId="user_id"
/>
}
placement="right"
rootClose={true}
trigger={
Array [
"click",
]
}
<span
aria-expanded="false"
aria-haspopup="dialog"
className="status-wrapper"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onPointerDown={[Function]}
>
<span
className="status-wrapper "
onClick={[Function]}
<RoundButton
className="style--none"
size="md"
>
<RoundButton
className="style--none"
<button
className="RoundButton-dvlhqG gfRzmz style--none"
size="md"
>
<button
className="RoundButton-dvlhqG gfRzmz style--none"
size="md"
<span
className="profile-icon "
>
<span
className="profile-icon "
<Memo(Avatar)
size="md"
url="/api/v4/users/user_id/image?_=0"
username="username"
>
<Memo(Avatar)
size="md"
tabIndex={-1}
url="/api/v4/users/user_id/image?_=0"
username="username"
>
<img
alt="username profile image"
className="Avatar Avatar-md"
loading="lazy"
onError={[Function]}
src="/api/v4/users/user_id/image?_=0"
tabIndex={-1}
/>
</Memo(Avatar)>
</span>
</button>
</RoundButton>
<Memo(StatusIcon)
status="status"
<img
alt="username profile image"
className="Avatar Avatar-md"
loading="lazy"
onError={[Function]}
src="/api/v4/users/user_id/image?_=0"
/>
</Memo(Avatar)>
</span>
</button>
</RoundButton>
<Memo(StatusIcon)
status="status"
>
<StatusOfflineIcon
className="status "
>
<StatusOfflineIcon
<span
className="status "
>
<span
className="status "
>
<svg
aria-label="Offline Icon"
className="offline--icon"
height="100%"
role="img"
style={
Object {
"clipRule": "evenodd",
"fillRule": "evenodd",
"strokeLinejoin": "round",
"strokeMiterlimit": 1.41421,
}
<svg
aria-label="Offline Icon"
className="offline--icon"
height="100%"
role="img"
style={
Object {
"clipRule": "evenodd",
"fillRule": "evenodd",
"strokeLinejoin": "round",
"strokeMiterlimit": 1.41421,
}
viewBox="0 0 20 20"
width="100%"
>
<path
d="M10,0c5.519,0 10,4.481 10,10c0,5.519 -4.481,10 -10,10c-5.519,0 -10,-4.481 -10,-10c0,-5.519 4.481,-10 10,-10Zm0,2c4.415,0 8,3.585 8,8c0,4.415 -3.585,8 -8,8c-4.415,0 -8,-3.585 -8,-8c0,-4.415 3.585,-8 8,-8Z"
/>
</svg>
</span>
</StatusOfflineIcon>
</Memo(StatusIcon)>
</span>
</OverlayTrigger>
</OverlayTrigger>
}
viewBox="0 0 20 20"
width="100%"
>
<path
d="M10,0c5.519,0 10,4.481 10,10c0,5.519 -4.481,10 -10,10c-5.519,0 -10,-4.481 -10,-10c0,-5.519 4.481,-10 10,-10Zm0,2c4.415,0 8,3.585 8,8c0,4.415 -3.585,8 -8,8c-4.415,0 -8,-3.585 -8,-8c0,-4.415 3.585,-8 8,-8Z"
/>
</svg>
</span>
</StatusOfflineIcon>
</Memo(StatusIcon)>
</span>
</ProfilePopoverController>
</ProfilePicture>
</div>
<div
@@ -912,161 +696,89 @@ exports[`components/drafts/panel/panel_body should match snapshot for requested_
>
<ProfilePicture
channelId="channel_id"
hasMention={false}
isEmoji={false}
popoverPlacement="right"
size="md"
src="/api/v4/users/user_id/image?_=0"
status="status"
userId="user_id"
username="username"
wrapperClass=""
>
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(ProfilePopover)
channelId="channel_id"
className="user-profile-popover"
hide={[Function]}
src="/api/v4/users/user_id/image?_=0"
userId="user_id"
/>
}
placement="right"
rootClose={true}
trigger={
Array [
"click",
]
}
<ProfilePopoverController
channelId="channel_id"
src="/api/v4/users/user_id/image?_=0"
triggerComponentClass="status-wrapper"
userId="user_id"
>
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<OverlayWrapper
channelId="channel_id"
className="user-profile-popover"
hide={[Function]}
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
"wrapRichTextChunksInFragment": undefined,
}
}
src="/api/v4/users/user_id/image?_=0"
userId="user_id"
/>
}
placement="right"
rootClose={true}
trigger={
Array [
"click",
]
}
<span
aria-expanded="false"
aria-haspopup="dialog"
className="status-wrapper"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onPointerDown={[Function]}
>
<span
className="status-wrapper "
onClick={[Function]}
<RoundButton
className="style--none"
size="md"
>
<RoundButton
className="style--none"
<button
className="RoundButton-dvlhqG gfRzmz style--none"
size="md"
>
<button
className="RoundButton-dvlhqG gfRzmz style--none"
size="md"
<span
className="profile-icon "
>
<span
className="profile-icon "
<Memo(Avatar)
size="md"
url="/api/v4/users/user_id/image?_=0"
username="username"
>
<Memo(Avatar)
size="md"
tabIndex={-1}
url="/api/v4/users/user_id/image?_=0"
username="username"
>
<img
alt="username profile image"
className="Avatar Avatar-md"
loading="lazy"
onError={[Function]}
src="/api/v4/users/user_id/image?_=0"
tabIndex={-1}
/>
</Memo(Avatar)>
</span>
</button>
</RoundButton>
<Memo(StatusIcon)
status="status"
<img
alt="username profile image"
className="Avatar Avatar-md"
loading="lazy"
onError={[Function]}
src="/api/v4/users/user_id/image?_=0"
/>
</Memo(Avatar)>
</span>
</button>
</RoundButton>
<Memo(StatusIcon)
status="status"
>
<StatusOfflineIcon
className="status "
>
<StatusOfflineIcon
<span
className="status "
>
<span
className="status "
>
<svg
aria-label="Offline Icon"
className="offline--icon"
height="100%"
role="img"
style={
Object {
"clipRule": "evenodd",
"fillRule": "evenodd",
"strokeLinejoin": "round",
"strokeMiterlimit": 1.41421,
}
<svg
aria-label="Offline Icon"
className="offline--icon"
height="100%"
role="img"
style={
Object {
"clipRule": "evenodd",
"fillRule": "evenodd",
"strokeLinejoin": "round",
"strokeMiterlimit": 1.41421,
}
viewBox="0 0 20 20"
width="100%"
>
<path
d="M10,0c5.519,0 10,4.481 10,10c0,5.519 -4.481,10 -10,10c-5.519,0 -10,-4.481 -10,-10c0,-5.519 4.481,-10 10,-10Zm0,2c4.415,0 8,3.585 8,8c0,4.415 -3.585,8 -8,8c-4.415,0 -8,-3.585 -8,-8c0,-4.415 3.585,-8 8,-8Z"
/>
</svg>
</span>
</StatusOfflineIcon>
</Memo(StatusIcon)>
</span>
</OverlayTrigger>
</OverlayTrigger>
}
viewBox="0 0 20 20"
width="100%"
>
<path
d="M10,0c5.519,0 10,4.481 10,10c0,5.519 -4.481,10 -10,10c-5.519,0 -10,-4.481 -10,-10c0,-5.519 4.481,-10 10,-10Zm0,2c4.415,0 8,3.585 8,8c0,4.415 -3.585,8 -8,8c-4.415,0 -8,-3.585 -8,-8c0,-4.415 3.585,-8 8,-8Z"
/>
</svg>
</span>
</StatusOfflineIcon>
</Memo(StatusIcon)>
</span>
</ProfilePopoverController>
</ProfilePicture>
</div>
<div

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

@@ -12,7 +12,6 @@ import UserProfile from 'components/user_profile';
import BotTag from 'components/widgets/tag/bot_tag';
import Tag from 'components/widgets/tag/tag';
import {Locations} from 'utils/constants';
import {fromAutoResponder, isFromWebhook} from 'utils/post_utils';
type Props = {
@@ -24,7 +23,6 @@ type Props = {
isBot: boolean;
isSystemMessage: boolean;
isMobileView: boolean;
location: keyof typeof Locations;
};
const PostUserProfile = (props: Props): JSX.Element | null => {
@@ -32,7 +30,6 @@ const PostUserProfile = (props: Props): JSX.Element | null => {
const {post, compactDisplay, isMobileView, isConsecutivePost, enablePostUsernameOverride, isBot, isSystemMessage, colorizeUsernames} = props;
const isFromAutoResponder = fromAutoResponder(post);
const colorize = compactDisplay && colorizeUsernames;
const isRHS = props.location === Locations.RHS_COMMENT || props.location === Locations.RHS_ROOT || props.location === Locations.SEARCH;
let userProfile: ReactNode = null;
let botIndicator = null;
@@ -55,7 +52,6 @@ const PostUserProfile = (props: Props): JSX.Element | null => {
<UserProfile
userId={post.user_id}
channelId={post.channel_id}
isRHS={isRHS}
colorize={colorize}
/>
);
@@ -66,7 +62,6 @@ const PostUserProfile = (props: Props): JSX.Element | null => {
<UserProfile
userId={post.user_id}
channelId={post.channel_id}
isRHS={isRHS}
colorize={colorize}
/>
);
@@ -75,7 +70,6 @@ const PostUserProfile = (props: Props): JSX.Element | null => {
<UserProfile
userId={post.user_id}
channelId={post.channel_id}
isRHS={isRHS}
colorize={colorize}
/>
);
@@ -105,7 +99,6 @@ const PostUserProfile = (props: Props): JSX.Element | null => {
userId={post.user_id}
channelId={post.channel_id}
hideStatus={true}
isRHS={isRHS}
colorize={colorize}
/>
</span>

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

@@ -226,7 +226,6 @@ exports[`components/post_edit_history should match snapshot 1`] = `
class="Avatar Avatar-sm avatar-post-preview"
loading="lazy"
src="/api/v4/users/user_id/image?_=0"
tabindex="0"
/>
</span>
<div

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

@@ -41,11 +41,10 @@ describe('components/PostProfilePicture', () => {
expect(screen.getByLabelText('Offline Icon')).toBeInTheDocument();
});
test('status and post icon override specified, default props', () => {
test('status is specified, default props', () => {
const props: Props = {
...baseProps,
status: 'away',
postIconOverrideURL: 'http://example.com/image.png',
};
renderWithContext(
<PostProfilePicture {...props}/>,

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

@@ -22,7 +22,6 @@ type Props = {
status?: string;
user: UserProfile;
isBot?: boolean;
postIconOverrideURL?: string;
overwriteIcon?: string;
}
@@ -93,7 +92,6 @@ export default class PostProfilePicture extends React.PureComponent<Props> {
}
const fromAutoResponder = PostUtils.fromAutoResponder(post);
const hasMention = !fromAutoResponder && !fromWebhook;
const profileSrc = this.getProfilePictureURL();
const src = this.getPostIconURL(profileSrc, fromAutoResponder, fromWebhook);
@@ -104,7 +102,6 @@ export default class PostProfilePicture extends React.PureComponent<Props> {
return (
<ProfilePicture
hasMention={hasMention}
size='md'
src={src}
profileSrc={profileSrc}

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

@@ -192,11 +192,11 @@ describe('components/post_view/ChannelIntroMessages', () => {
);
expect(screen.getByText('This is the start of your direct message history with my teammate.', {exact: false})).toBeInTheDocument();
const teammate = screen.getByLabelText('my teammate');
const teammate = screen.getByText('my teammate');
expect(teammate).toBeInTheDocument();
expect(teammate).toHaveTextContent('my teammate');
expect(teammate).toHaveClass('user-popover style--none');
expect(teammate).toHaveClass('style--none');
const image = screen.getByRole('img');

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

@@ -277,13 +277,11 @@ function createDMIntroMessage(
status={teammate.is_bot ? '' : channel.status}
userId={teammate?.id}
username={teammate?.username}
hasMention={true}
/>
</div>
<h2 className='channel-intro__title'>
<UserProfile
userId={teammate?.id}
disablePopover={false}
/>
</h2>
<p className='channel-intro__text'>

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

@@ -15,7 +15,6 @@ exports[`components/post_view/CommentedOn should match snapshot 1`] = `
className="theme user_name"
>
<Memo(Connect(UserProfile))
disablePopover={false}
userId=""
/>
</a>,
@@ -47,7 +46,6 @@ exports[`components/post_view/CommentedOn should match snapshot 2`] = `
className="theme user_name"
>
<Memo(Connect(UserProfile))
disablePopover={false}
userId=""
/>
</a>,
@@ -79,7 +77,6 @@ exports[`components/post_view/CommentedOn should match snapshot 3`] = `
className="theme user_name"
>
<Memo(Connect(UserProfile))
disablePopover={false}
userId=""
/>
</a>,
@@ -113,7 +110,6 @@ exports[`components/post_view/CommentedOn should match snapshots for post with p
className="theme user_name"
>
<Memo(Connect(UserProfile))
disablePopover={false}
userId=""
/>
</a>,
@@ -145,7 +141,6 @@ exports[`components/post_view/CommentedOn should match snapshots for post with p
className="theme user_name"
>
<Memo(Connect(UserProfile))
disablePopover={false}
userId=""
/>
</a>,
@@ -177,7 +172,6 @@ exports[`components/post_view/CommentedOn should match snapshots for post with p
className="theme user_name"
>
<Memo(Connect(UserProfile))
disablePopover={false}
userId=""
/>
</a>,
@@ -209,7 +203,6 @@ exports[`components/post_view/CommentedOn should match snapshots for post with p
className="theme user_name"
>
<Memo(Connect(UserProfile))
disablePopover={false}
userId=""
/>
</a>,

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

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

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

@@ -2,7 +2,7 @@
exports[`components/ProfilePicture should match snapshot, no user specified, default props 1`] = `
<span
className="status-wrapper style--none "
className="status-wrapper style--none"
>
<span
className="profile-icon "
@@ -20,7 +20,7 @@ exports[`components/ProfilePicture should match snapshot, no user specified, def
exports[`components/ProfilePicture should match snapshot, no user specified, overridden props 1`] = `
<span
className="status-wrapper style--none "
className="status-wrapper style--none"
>
<span
className="profile-icon "
@@ -37,51 +37,32 @@ exports[`components/ProfilePicture should match snapshot, no user specified, ove
`;
exports[`components/ProfilePicture should match snapshot, profile and src, default props 1`] = `
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
src="http://example.com/image.png"
userId="uid"
/>
}
placement="right"
rootClose={true}
trigger={
Array [
"click",
]
}
<ProfilePopoverController
src="http://example.com/image.png"
triggerComponentClass="status-wrapper"
userId="uid"
>
<span
className="status-wrapper "
<RoundButton
className="style--none"
size="md"
>
<RoundButton
className="style--none"
size="md"
<span
className="profile-icon "
>
<span
className="profile-icon "
>
<Memo(Avatar)
size="md"
tabIndex={-1}
url="http://example.com/emoji.png"
/>
</span>
</RoundButton>
<Memo(StatusIcon)
status="away"
/>
</span>
</OverlayTrigger>
<Memo(Avatar)
url="http://example.com/emoji.png"
/>
</span>
</RoundButton>
<Memo(StatusIcon)
status="away"
/>
</ProfilePopoverController>
`;
exports[`components/ProfilePicture should match snapshot, user specified 1`] = `
<span
className="status-wrapper style--none "
className="status-wrapper style--none"
>
<span
className="profile-icon "
@@ -99,7 +80,7 @@ exports[`components/ProfilePicture should match snapshot, user specified 1`] = `
exports[`components/ProfilePicture should match snapshot, user specified, overridden props 1`] = `
<span
className="status-wrapper style--none "
className="status-wrapper style--none"
>
<span
className="profile-icon "

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

@@ -0,0 +1,101 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import styled from 'styled-components';
import ProfilePopover from 'components/profile_popover';
import StatusIcon from 'components/status_icon';
import StatusIconNew from 'components/status_icon_new';
import Avatar, {getAvatarWidth} from 'components/widgets/users/avatar';
import type {TAvatarSizeToken} from 'components/widgets/users/avatar';
type Props = {
size?: TAvatarSizeToken;
isEmoji?: boolean;
wrapperClass?: string;
profileSrc?: string;
src: string;
isBot?: boolean;
fromAutoResponder?: boolean;
status?: string;
fromWebhook?: boolean;
userId?: string;
channelId?: string;
username?: string;
overwriteIcon?: string;
overwriteName?: string;
newStatusIcon?: boolean;
statusClass?: string;
}
function ProfilePicture(props: Props) {
// profileSrc will, if possible, be the original user profile picture even if the icon
// for the post is overriden, so that the popup shows the user identity
const profileSrc = typeof props.profileSrc === 'string' && props.profileSrc !== '' ? props.profileSrc : props.src;
const profileIconClass = `profile-icon ${props.isEmoji ? 'emoji' : ''}`;
const hideStatus = props.isBot || props.fromAutoResponder || props.fromWebhook;
if (props.userId) {
return (
<ProfilePopover
triggerComponentClass={classNames('status-wrapper', props.wrapperClass)}
userId={props.userId}
src={profileSrc}
channelId={props.channelId}
hideStatus={hideStatus}
overwriteIcon={props.overwriteIcon}
overwriteName={props.overwriteName}
fromWebhook={props.fromWebhook}
>
<>
<RoundButton
className='style--none'
size={props?.size ?? 'md'}
>
<span className={profileIconClass}>
<Avatar
username={props.username}
size={props.size}
url={props.src}
/>
</span>
</RoundButton>
<StatusIcon status={props.status}/>
</>
</ProfilePopover>
);
}
return (
<span
className={classNames('status-wrapper', 'style--none', props.wrapperClass)}
>
<span className={profileIconClass}>
<Avatar
size={props?.size ?? 'md'}
url={props.src}
/>
</span>
{props.newStatusIcon ? (
<StatusIconNew
className={props.statusClass}
status={props.status}
/>
) : (
<StatusIcon status={props.status}/>
)}
</span>
);
}
const RoundButton = styled.button<{size: TAvatarSizeToken}>`
border-radius: 50%;
width: ${(p) => getAvatarWidth(p.size)}px;
height: ${(p) => getAvatarWidth(p.size)}px;
`;
export default ProfilePicture;

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

@@ -1,3 +0,0 @@
.ProfilePicture {
border-radius: 50%;
}

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

@@ -1,136 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {ComponentProps} from 'react';
import styled from 'styled-components';
import OverlayTrigger from 'components/overlay_trigger';
import type {BaseOverlayTrigger} from 'components/overlay_trigger';
import ProfilePopover from 'components/profile_popover';
import StatusIcon from 'components/status_icon';
import StatusIconNew from 'components/status_icon_new';
import Avatar, {getAvatarWidth} from 'components/widgets/users/avatar';
import type {TAvatarSizeToken} from 'components/widgets/users/avatar';
import './profile_picture.scss';
interface MMOverlayTrigger extends BaseOverlayTrigger {
hide: () => void;
}
type Props = {
isEmoji?: boolean;
profileSrc?: string;
size?: ComponentProps<typeof Avatar>['size'];
src: string;
status?: string;
userId?: string;
channelId?: string;
username?: string;
wrapperClass?: string;
overwriteIcon?: string;
overwriteName?: string;
newStatusIcon?: boolean;
statusClass?: string;
isBot?: boolean;
fromWebhook?: boolean;
fromAutoResponder?: boolean;
popoverPlacement?: string;
}
export default class ProfilePicture extends React.PureComponent<Props> {
public static defaultProps = {
size: 'md',
isEmoji: false,
hasMention: false,
wrapperClass: '',
popoverPlacement: 'right',
};
overlay = React.createRef<MMOverlayTrigger>();
buttonRef = React.createRef<HTMLButtonElement>();
public hideProfilePopover = () => {
if (this.overlay.current) {
this.overlay.current.hide();
}
};
public render() {
// profileSrc will, if possible, be the original user profile picture even if the icon
// for the post is overriden, so that the popup shows the user identity
const profileSrc = (typeof this.props.profileSrc === 'string' && this.props.profileSrc !== '') ? this.props.profileSrc : this.props.src;
const profileIconClass = `profile-icon ${this.props.isEmoji ? 'emoji' : ''}`;
const hideStatus = this.props.isBot || this.props.fromAutoResponder || this.props.fromWebhook;
if (this.props.userId) {
return (
<OverlayTrigger
ref={this.overlay}
trigger={['click']}
placement={this.props.popoverPlacement}
rootClose={true}
overlay={
<ProfilePopover
className='user-profile-popover'
userId={this.props.userId}
src={profileSrc}
hide={this.hideProfilePopover}
channelId={this.props.channelId}
overwriteIcon={this.props.overwriteIcon}
overwriteName={this.props.overwriteName}
fromWebhook={this.props.fromWebhook}
hideStatus={hideStatus}
/>
}
>
<span className={`status-wrapper ${this.props.wrapperClass}`}>
<RoundButton
className='style--none'
size={this.props.size ?? 'md'}
ref={this.buttonRef}
>
<span className={profileIconClass}>
<Avatar
username={this.props.username}
size={this.props.size}
url={this.props.src}
tabIndex={-1}
/>
</span>
</RoundButton>
<StatusIcon status={this.props.status}/>
</span>
</OverlayTrigger>
);
}
return (
<span className={`status-wrapper style--none ${this.props.wrapperClass}`}>
<span className={profileIconClass}>
<Avatar
size={this.props.size}
url={this.props.src}
/>
</span>
{this.props.newStatusIcon ? (
<StatusIconNew
className={this.props.statusClass}
status={this.props.status}
/>
) : <StatusIcon status={this.props.status}/>}
</span>
);
}
}
const RoundButton = styled.button<{size: TAvatarSizeToken}>`
border-radius: 50%;
width: ${(p) => getAvatarWidth(p.size)}px;
height: ${(p) => getAvatarWidth(p.size)}px;
`;

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

@@ -1,4 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default} from './profile_picture';
import {ProfilePopoverController} from './profile_popover_controller';
export default ProfilePopoverController;

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

@@ -1,335 +0,0 @@
// 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 {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();
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
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,139 +1,226 @@
@import 'utils/mixins';
.user-popover__set-custom-status-btn {
display: flex;
width: max-content;
align-items: center;
padding: 2px 0;
border: none;
background: transparent;
color: var(--button-bg);
font-size: 12px;
font-weight: 600;
gap: 6px;
line-height: 10px;
}
.user-profile-popover__heading {
font-family: Metropolis, sans-serif;
font-size: 20px;
font-weight: 600;
line-height: 24px;
text-align: center;
}
.user-popover-last-active {
display: block;
margin: 8px 0 4px;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 11px;
font-weight: 400;
letter-spacing: 0.02em;
line-height: 16px;
text-align: center;
.user-profile-popover-floating-overlay {
// 99 being the z-index of the global header
// 1060 being the z-index of the user group popover
z-index: 1070;
}
.user-profile-popover {
width: 240px;
max-height: calc(100vh - 240px);
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
background: var(--center-channel-bg);
box-shadow: var(--elevation-4);
overflow-y: auto;
.popover-title {
overflow: unset;
max-width: 110%;
padding: 0;
border-bottom: none;
margin-top: 8px;
margin-right: -8px;
&.popover-title_height {
overflow: visible;
height: 22px;
}
.user-profile-popover-title {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: end;
padding: 12px 8px 12px 8px;
.user-popover__role {
padding: 0 4px;
margin-left: 0;
background-color: rgba(var(--center-channel-color-rgb), 0.08);
color: var(--center-channel-color-rgb);
font-family: Open Sans, sans-serif;
font-size: 10px;
letter-spacing: 0.02em;
line-height: 16px;
flex-grow: 1;
background: unset;
padding-inline-start: 8px;
> span {
height: 16px;
padding: 0 4px;
border-radius: 4px;
background-color: rgba(var(--center-channel-color-rgb), 0.08);
color: var(--center-channel-color-rgb);
font-family: Open Sans, sans-serif;
font-size: 10px;
letter-spacing: 0.2px;
line-height: 16px;
text-transform: uppercase;
}
}
.closeButtonRelativePosition {
position: absolute;
top: 0;
right: 0;
margin: 8px 8px 0 0;
}
}
.popover-content {
padding: 0 16px;
.user-profile-popover-content {
.user-popover-image {
position: relative;
width: 128px;
margin: 0 auto 8px auto;
.overflow--ellipsis {
#userAvatar {
width: 120px;
min-width: 120px;
height: 120px;
}
.user-popover-status {
position: absolute;
top: auto;
right: 8px;
bottom: 0;
display: flex;
width: 24px;
height: 24px;
padding: 2px;
border-radius: 50px;
background: rgba(var(--center-channel-bg-rgb), 1);
svg {
width: 100%;
min-height: 100%;
}
}
}
.user-popover-last-active {
display: block;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-family: "Open Sans";
font-size: 11px;
font-weight: 400;
letter-spacing: 0.02em;
line-height: 16px;
margin-block-end: 8px;
padding-inline-end: 16px;
padding-inline-start: 16px;
text-align: center;
}
.user-popover__subtitle {
margin-bottom: 4px;
font-size: 11px;
font-weight: 600;
line-height: 16px;
.user-profile-popover__heading {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: center;
padding-inline-end: 16px;
padding-inline-start: 16px;
text-align: center;
> h5 {
margin:0;
font-family: Metropolis, sans-serif;
font-size: 20px;
font-weight: 600;
line-height: 28px;
@include textEllipsis;
}
i.shared-user-icon {
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 16px;
}
}
.user-profile-popover__non-heading {
font-family: "Open Sans";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-block-end: 4px;
padding-inline-end: 16px;
padding-inline-start: 16px;
text-align: center;
@include textEllipsis;
}
hr {
border-color: rgba(var(--center-channel-color-rgb), 0.08);
margin-block-end: 16px;
margin-block-start: 16px;
&.user-popover__bottom-row-hr {
margin-block-start: 4px;
}
}
.user-profile-popover__email {
display: flex;
align-items: center;
margin: 0 16px 10px 16px;
justify-self: start;
i.icon-email-outline {
margin-inline-end: 8px;
&::before {
margin-inline-end: 0;
margin-inline-start: 0;
}
}
a {
font-family: "Open Sans";
font-size: 14px;
font-weight: 400;
line-height: 20px;
@include textEllipsis;
}
}
.user-popover__custom-status {
display: flex;
align-items: center;
font-family: "Open Sans";
font-size: 14px;
span.emoticon {
font-size: 16px;
}
}
.user-popover__time-status-container {
display: flex;
flex-direction: column;
margin-bottom: 8px;
padding: 0 16px 12px 16px;
@include textEllipsis;
.user-popover__subtitle {
display: flex;
margin-bottom: 4px;
font-size: 11px;
font-weight: 600;
gap: 4px;
line-height: 16px;
@include textEllipsis;
}
.user-popover__subtitle-text {
margin: 0;
}
.user-popover__set-status {
width: fit-content;
}
}
.popover__row {
.user-popover__bottom-row-container {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: 16px;
gap: 12px;
padding: 0 16px 16px 16px;
margin: 0;
gap: 4px;
row-gap: 8px;
&.first {
border-top-color: rgba(var(--center-channel-color-rgb), 0.08);
}
.btn {
@include primary-button;
height: 32px;
padding: 8px 12px;
gap: 6px;
span {
font-size: 12px;
font-weight: 600;
line-height: 10px;
}
}
.icon-btn {
width: 32px;
padding: 0;
background-color: unset;
color: rgba(var(--center-channel-color-rgb), 0.64);
&:hover {
border-radius: 4px;
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.8);
}
}
.icon-btn-disabled {
cursor: not-allowed;
opacity: 0.65;
}
.popover_row-controlContainer {
.user-popover__bottom-row-end {
display: flex;
flex-wrap: wrap;
align-items: center;
.callButtonContainer {
height: unset;
margin-top: 0;
}
gap: 4px;
justify-items: flex-end;
row-gap: 4px;
}
}
}

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

@@ -18,7 +18,7 @@ import {getDirectChannelName} from 'utils/utils';
import type {GlobalState} from 'types/store';
import ProfilePopover from '.';
import ProfilePopover from './profile_popover';
jest.mock('@mattermost/client', () => ({
...jest.requireActual('@mattermost/client'),

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

@@ -0,0 +1,242 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {getCurrentChannelId, getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
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 {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 Pluggable from 'plugins/pluggable';
import {getHistory} from 'utils/browser_history';
import {A11yCustomEventTypes, UserStatuses} from 'utils/constants';
import type {A11yFocusEventDetail} from 'utils/constants';
import * as Utils from 'utils/utils';
import type {GlobalState} from 'types/store';
import ProfilePopoverAvatar from './profile_popover_avatar';
import ProfilePopoverCustomStatus from './profile_popover_custom_status';
import ProfilePopoverEmail from './profile_popover_email';
import ProfilePopoverLastActive from './profile_popover_last_active';
import ProfilePopoverName from './profile_popover_name';
import ProfilePopoverOtherUserRow from './profile_popover_other_user_row';
import ProfilePopoverOverrideDisclaimer from './profile_popover_override_disclaimer';
import ProfilePopoverSelfUserRow from './profile_popover_self_user_row';
import ProfilePopoverTimezone from './profile_popover_timezone';
import ProfilePopoverTitle from './profile_popover_title';
import './profile_popover.scss';
export interface Props {
userId: string;
src: string;
channelId?: string;
hideStatus?: boolean;
fromWebhook?: boolean;
hide?: () => void;
returnFocus?: () => void;
overwriteIcon?: string;
overwriteName?: string;
}
function getDefaultChannelId(state: GlobalState) {
const selectedPost = getSelectedPost(state);
return selectedPost.exists ? selectedPost.channel_id : getCurrentChannelId(state);
}
/**
* The profile popover, or hover card, that appears with user information when clicking
* on the username, profile picture of a user, or others.
* However this component should not be used directly, instead use the `ProfilePopoverController` which is
* what is default exported from 'components/profile_popover'.
*/
const ProfilePopover = ({
userId,
src,
channelId: channelIdProp,
hideStatus,
fromWebhook,
hide,
returnFocus,
overwriteIcon,
overwriteName,
}: Props) => {
const dispatch = useDispatch();
const user = useSelector((state: GlobalState) => getUser(state, userId));
const currentTeamId = useSelector((state: GlobalState) => getCurrentTeamId(state));
const channelId = useSelector((state: GlobalState) => (channelIdProp || getDefaultChannelId(state)));
const isMobileView = useSelector(getIsMobileView);
const teamUrl = useSelector(getCurrentRelativeTeamUrl);
const modals = useSelector((state: GlobalState) => state.views.modals);
const status = useSelector((state: GlobalState) => getStatusForUserId(state, userId) || UserStatuses.OFFLINE);
const currentUserTimezone = useSelector(getCurrentTimezone);
const currentUserId = useSelector(getCurrentUserId);
const [loadingDMChannel, setLoadingDMChannel] = useState<string>();
const handleReturnFocus = useMemo(() => {
if (returnFocus) {
return returnFocus;
}
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]);
useEffect(() => {
if (currentTeamId && userId) {
dispatch(getMembershipForEntities(
currentTeamId,
userId,
channelId,
));
}
}, []);
if (!user) {
return null;
}
const urlSrc = overwriteIcon || src;
const haveOverrideProp = Boolean(overwriteIcon || overwriteName);
const fullname = overwriteName || Utils.getFullName(user);
return (
<>
<ProfilePopoverTitle
channelId={channelId}
isBot={user.is_bot}
returnFocus={handleReturnFocus}
roles={user.roles}
userId={user.id}
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/>
<ProfilePopoverEmail
email={user.email}
haveOverrideProp={haveOverrideProp}
isBot={user.is_bot}
/>
<Pluggable
pluggableName='PopoverUserAttributes'
user={user}
hide={hide}
status={hideStatus ? null : status}
fromWebhook={fromWebhook}
/>
<ProfilePopoverTimezone
currentUserTimezone={currentUserTimezone}
profileUserTimezone={user.timezone}
haveOverrideProp={haveOverrideProp}
/>
<ProfilePopoverCustomStatus
currentUserId={currentUserId}
currentUserTimezone={currentUserTimezone}
haveOverrideProp={haveOverrideProp}
hideStatus={hideStatus}
user={user}
returnFocus={handleReturnFocus}
hide={hide}
/>
<hr className='user-popover__bottom-row-hr'/>
<ProfilePopoverOverrideDisclaimer
haveOverrideProp={haveOverrideProp}
username={user.username}
/>
<ProfilePopoverSelfUserRow
currentUserId={currentUserId}
handleCloseModals={handleCloseModals}
handleShowDirectChannel={handleShowDirectChannel}
haveOverrideProp={haveOverrideProp}
returnFocus={handleReturnFocus}
userId={user.id}
hide={hide}
/>
<ProfilePopoverOtherUserRow
currentUserId={currentUserId}
fullname={fullname}
handleCloseModals={handleCloseModals}
handleShowDirectChannel={handleShowDirectChannel}
haveOverrideProp={haveOverrideProp}
returnFocus={handleReturnFocus}
user={user}
hide={hide}
/>
<Pluggable
pluggableName='PopoverUserActions'
user={user}
hide={hide}
status={hideStatus ? null : status}
/>
</div>
</>
);
};
export default React.memo(ProfilePopover);

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

@@ -5,18 +5,16 @@ 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 WithTooltip from 'components/with_tooltip';
import Constants, {ModalIdentifiers} from 'utils/constants';
import {ModalIdentifiers} from 'utils/constants';
import type {GlobalState} from 'types/store';
@@ -33,7 +31,7 @@ function getIsInCurrentTeam(state: GlobalState, userId: string) {
return Boolean(teamMember) && teamMember?.delete_at === 0;
}
const AddToChannel = ({
const ProfilePopoverAddToChannel = ({
handleCloseModals,
returnFocus,
user,
@@ -52,42 +50,38 @@ const AddToChannel = ({
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}
<WithTooltip
id='user_profile.add_user_to_channel.icon'
title={formatMessage({
id: 'user_profile.add_user_to_channel',
defaultMessage: 'Add to a Channel',
})}
placement='top'
overlay={
<Tooltip id='addToChannelTooltip'>
{addToChannelMessage}
</Tooltip>
}
>
<div>
{/* This span is necessary as tooltip is not able to pass trigger props to a custom component */}
<span>
<ToggleModalButton
id='addToChannelButton'
className='btn icon-btn'
ariaLabel={addToChannelMessage}
className='btn btn-icon btn-sm'
ariaLabel={formatMessage({
id: 'user_profile.add_user_to_channel',
defaultMessage: 'Add to a Channel',
})}
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',
})}
<i
className='icon icon-account-plus-outline'
aria-hidden='true'
/>
</ToggleModalButton>
</div>
</OverlayTrigger>
</span>
</WithTooltip>
);
};
export default AddToChannel;
export default ProfilePopoverAddToChannel;

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

@@ -0,0 +1,25 @@
// 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';
type Props = {
botDescription: UserProfile['bot_description'];
}
const ProfilePopoverBotDescription = ({
botDescription,
}: Props) => {
return (
<p
className='user-profile-popover__non-heading'
title={botDescription}
>
{botDescription}
</p>
);
};
export default ProfilePopoverBotDescription;

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

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {isUserInCall} from './call_button';
import {isUserInCall} from './index';
describe('isUserInCall', () => {
test('missing state', () => {

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

@@ -14,7 +14,7 @@ import {getChannelByName} from 'mattermost-redux/selectors/entities/channels';
import {isCallsEnabled as getIsCallsEnabled, getSessionsInCalls} from 'selectors/calls';
import OverlayTrigger from 'components/overlay_trigger';
import ProfilePopoverCallButton from 'components/profile_popover_call_button';
import ProfilePopoverCallButton from 'components/profile_popover/profile_popover_calls_button';
import Tooltip from 'components/tooltip';
import Constants from 'utils/constants';
@@ -98,7 +98,7 @@ const CallButton = ({
id: 'webapp.mattermost.feature.start_call',
defaultMessage: 'Start Call',
});
const iconButtonClassname = classNames('btn icon-btn', {'icon-btn-disabled': disabled});
const iconButtonClassname = classNames('style--none btn btn-icon btn-sm', {'icon-btn-disabled': disabled});
const callButton = (
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}

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

@@ -0,0 +1,6 @@
button#startCallButton {
&.btn-disabled-styled {
cursor: not-allowed;
opacity: 0.65;
}
}

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

@@ -64,7 +64,6 @@ export default function ProfilePopoverCallButton({pluginCallComponents, channelM
return (
<div
className='callButtonContainer flex-child'
onClick={clickHandler}
onTouchEnd={clickHandler}
>

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

@@ -0,0 +1,162 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
useFloating,
autoUpdate,
autoPlacement,
useTransitionStyles,
useClick,
useDismiss,
useInteractions,
useRole,
FloatingFocusManager,
FloatingOverlay,
FloatingPortal,
} from '@floating-ui/react';
import classNames from 'classnames';
import type {HtmlHTMLAttributes, ReactNode} from 'react';
import React, {useCallback, useState} from 'react';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import {A11yClassNames} from 'utils/constants';
import ProfilePopover from './profile_popover';
const PROFILE_POPOVER_OPENING_DELAY = 300;
const PROFILE_POPOVER_CLOSING_DELAY = 500;
interface Props<TriggerComponentType> {
/**
* The Props for the trigger component
*/
triggerComponentAs?: React.ElementType;
triggerComponentId?: HtmlHTMLAttributes<TriggerComponentType>['id'];
triggerComponentClass?: HtmlHTMLAttributes<TriggerComponentType>['className'];
triggerComponentStyle?: HtmlHTMLAttributes<TriggerComponentType>['style'];
/**
* Source URL from the image to display in the popover
*/
src: string;
/**
* This should be the trigger button for the popover, Do note that the root element of the trigger component should be passed in triggerComponentRoot
*/
children: ReactNode;
userId: UserProfile['id'];
channelId?: Channel['id'];
/**
* The overwritten username that should be shown at the top of the popover
*/
overwriteName?: 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;
hideStatus?: boolean;
/**
* 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;
onToggle?: (isMounted: boolean) => void;
}
export function ProfilePopoverController<TriggerComponentType = HTMLSpanElement>(props: Props<TriggerComponentType>) {
const [isOpen, setOpen] = useState(false);
const {refs, floatingStyles, context: floatingContext} = useFloating({
open: isOpen,
onOpenChange: setOpen,
whileElementsMounted: autoUpdate,
middleware: [autoPlacement()],
});
const {isMounted, styles: transitionStyles} = useTransitionStyles(floatingContext, {
duration: {
open: PROFILE_POPOVER_OPENING_DELAY,
close: PROFILE_POPOVER_CLOSING_DELAY,
},
});
const combinedFloatingStyles = Object.assign({}, floatingStyles, transitionStyles);
const clickInteractions = useClick(floatingContext);
const dismissInteraction = useDismiss(floatingContext);
const role = useRole(floatingContext);
const {getReferenceProps, getFloatingProps} = useInteractions([
clickInteractions,
dismissInteraction,
role,
]);
const handleHide = useCallback(() => {
setOpen(false);
}, []);
const TriggerComponent = props.triggerComponentAs ?? 'span';
return (
<>
<TriggerComponent
id={props.triggerComponentId}
ref={refs.setReference}
className={props.triggerComponentClass}
style={props.triggerComponentStyle}
{...getReferenceProps()}
>
{props.children}
</TriggerComponent>
{isMounted && (
<FloatingPortal id='user-profile-popover-portal'>
<FloatingOverlay
id='user-profile-popover-floating-overlay'
className='user-profile-popover-floating-overlay'
lockScroll={true}
>
<FloatingFocusManager context={floatingContext}>
<div
ref={refs.setFloating}
style={combinedFloatingStyles}
className={classNames('user-profile-popover', A11yClassNames.POPUP)}
{...getFloatingProps()}
>
<ProfilePopover
userId={props.userId}
src={props.src}
channelId={props.channelId}
hideStatus={props.hideStatus}
fromWebhook={props.fromWebhook}
hide={handleHide}
returnFocus={props.returnFocus}
overwriteIcon={props.overwriteIcon}
overwriteName={props.overwriteName}
/>
</div>
</FloatingFocusManager>
</FloatingOverlay>
</FloatingPortal>
)}
</>
);
}

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

@@ -5,7 +5,6 @@ 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';
@@ -32,8 +31,9 @@ type Props = {
}
const emojiStyles: React.CSSProperties = {
marginRight: 4,
marginTop: 1,
marginRight: 8,
width: 16,
height: 16,
};
const ProfilePopoverCustomStatus = ({
currentUserId,
@@ -72,7 +72,10 @@ const ProfilePopoverCustomStatus = ({
let customStatusContent;
if (customStatusSet) {
customStatusContent = (
<div className='d-flex align-items-center'>
<div
className='user-popover__custom-status'
aria-labelledby='user-popover__custom-status-title'
>
<CustomStatusEmoji
userID={user.id}
showTooltip={false}
@@ -81,17 +84,16 @@ const ProfilePopoverCustomStatus = ({
<CustomStatusText
tooltipDirection='top'
text={customStatus.text || ''}
className='user-popover__email'
/>
</div>
);
} else if (canSetCustomStatus) {
customStatusContent = (
<button
className='user-popover__set-custom-status-btn'
className='btn btn-sm btn-quaternary user-popover__set-status'
onClick={showCustomStatusModal}
>
<EmoticonHappyOutlineIcon size={14}/>
<i className='icon icon-emoticon-plus-outline'/>
<FormattedMessage
id='user_profile.custom_status.set_status'
defaultMessage='Set a status'
@@ -105,7 +107,10 @@ const ProfilePopoverCustomStatus = ({
id='user-popover-status'
className='user-popover__time-status-container'
>
<span className='user-popover__subtitle'>
<strong
id='user-popover__custom-status-title'
className='user-popover__subtitle'
>
<FormattedMessage
id='user_profile.custom_status'
defaultMessage='Status'
@@ -114,11 +119,10 @@ const ProfilePopoverCustomStatus = ({
<ExpiryTime
time={customStatus.expires_at!} // has to be defined since showExpiryTime is true
timezone={currentUserTimezone}
className='ml-1'
withinBrackets={true}
/>
)}
</span>
</strong>
{customStatusContent}
</div>
);

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

@@ -4,9 +4,9 @@
import React from 'react';
type Props = {
email: string;
haveOverrideProp: boolean;
isBot: boolean;
email?: string;
haveOverrideProp?: boolean;
isBot?: boolean;
}
const ProfilePopoverEmail = ({
email,
@@ -16,14 +16,19 @@ const ProfilePopoverEmail = ({
if (!email || isBot || haveOverrideProp) {
return null;
}
return (
<div
data-toggle='tooltip'
title={email}
className='user-profile-popover__email'
>
<i
className='icon icon-email-outline'
aria-hidden='true'
/>
<a
href={'mailto:' + email}
className='text-nowrap text-lowercase user-popover__email pb-1'
>
{email}
</a>

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

@@ -10,7 +10,7 @@ type Props = {
username: string;
remoteId?: string;
}
const FullName = ({
const ProfilePopoverFullName = ({
fullname,
username,
remoteId,
@@ -19,7 +19,7 @@ const FullName = ({
return null;
}
let sharedIcon;
let sharedIcon = null;
if (remoteId) {
sharedIcon = (
<SharedUserIndicator
@@ -29,14 +29,16 @@ const FullName = ({
/>
);
}
return (
<div
data-testid={`popover-fullname-${username}`}
className='overflow--ellipsis pb-1'
className='user-profile-popover__heading'
>
<span className='user-profile-popover__heading'>{fullname}</span>
<h5 title={fullname}>{fullname}</h5>
{sharedIcon}
</div>
);
};
export default FullName;
export default ProfilePopoverFullName;

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

@@ -5,10 +5,10 @@ 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';
import BotDescription from 'components/profile_popover/profile_popover_bot_description';
import FullName from 'components/profile_popover/profile_popover_full_name';
import Position from 'components/profile_popover/profile_popover_position';
import UserName from 'components/profile_popover/profile_popover_user_name';
type Props = {
haveOverrideProp: boolean;
@@ -27,19 +27,20 @@ const ProfilePopoverName = ({
remoteId={user.remote_id}
username={user.username}
/>
<BotDescription
botDescription={user.bot_description}
haveOverrideProp={haveOverrideProp}
isBot={user.is_bot}
/>
{(user.is_bot && !haveOverrideProp) && (
<BotDescription
botDescription={user.bot_description}
/>
)}
<UserName
hasFullName={Boolean(fullname)}
username={user.username}
/>
<Position
haveOverrideProp={haveOverrideProp}
position={user.position}
/>
{(user.position && !haveOverrideProp) && (
<Position
position={user.position}
/>
)}
</>
);
};

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

@@ -1,26 +0,0 @@
// 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;

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

@@ -1,30 +0,0 @@
// 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;

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

@@ -2,13 +2,12 @@
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {FormattedMessage} 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';
import ProfilePopoverAddToChannel from 'components/profile_popover/profile_popover_add_to_channel';
import ProfilePopoverCallButtonWrapper from 'components/profile_popover/profile_popover_call_button_wrapper';
type Props = {
user: UserProfile;
@@ -21,7 +20,7 @@ type Props = {
hide?: () => void;
};
const ProfilePopoverActions = ({
const ProfilePopoverOtherUserRow = ({
currentUserId,
haveOverrideProp,
user,
@@ -31,42 +30,34 @@ const ProfilePopoverActions = ({
hide,
fullname,
}: Props) => {
const {formatMessage} = useIntl();
if (user.id === currentUserId || haveOverrideProp) {
return null;
}
return (
<div
data-toggle='tooltip'
className='popover__row first'
>
<div className='user-popover__bottom-row-container'>
<button
id='messageButton'
type='button'
className='btn'
className='btn btn-primary btn-sm'
onClick={handleShowDirectChannel}
>
<SendIcon
size={16}
aria-label={formatMessage({
id: 'user_profile.send.dm.icon',
defaultMessage: 'Send Message Icon',
})}
<i
className='icon icon-send'
aria-hidden='true'
/>
<FormattedMessage
id='user_profile.send.dm'
defaultMessage='Message'
/>
</button>
<div className='popover_row-controlContainer'>
<AddToChannel
<div className='user-popover__bottom-row-end'>
<ProfilePopoverAddToChannel
handleCloseModals={handleCloseModals}
returnFocus={returnFocus}
user={user}
hide={hide}
/>
<CallButton
<ProfilePopoverCallButtonWrapper
currentUserId={currentUserId}
fullname={fullname}
userId={user.id}
@@ -77,4 +68,4 @@ const ProfilePopoverActions = ({
);
};
export default ProfilePopoverActions;
export default ProfilePopoverOtherUserRow;

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

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useIntl} from 'react-intl';
type Props = {
username: string;
@@ -12,23 +12,24 @@ const ProfilePopoverOverrideDisclaimer = ({
username,
haveOverrideProp,
}: Props) => {
const {formatMessage} = useIntl();
if (!haveOverrideProp) {
return null;
}
return (
<div
data-toggle='tooltip'
className='popover__row first'
<p
className='user-popover__bottom-row-container'
>
<FormattedMessage
id='user_profile.account.post_was_created'
defaultMessage='This post was created by an integration from @{username}'
values={{
username,
}}
/>
</div>
{formatMessage({
id: 'user_profile.account.post_was_created',
defaultMessage: 'This post was created by an integration from @{username}',
},
{
username,
})}
</p>
);
};

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

@@ -0,0 +1,29 @@
// 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 Constants from 'utils/constants';
type Props = {
position: UserProfile['position'];
}
const ProfilePopoverPosition = ({
position,
}: Props) => {
const positionSubstringed = (position).substring(0, Constants.MAX_POSITION_LENGTH);
return (
<p
className='user-profile-popover__non-heading'
title={position}
>
{positionSubstringed}
</p>
);
};
export default ProfilePopoverPosition;

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

@@ -5,15 +5,12 @@ 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 WithTooltip from 'components/with_tooltip';
import Constants, {ModalIdentifiers} from 'utils/constants';
import {ModalIdentifiers} from 'utils/constants';
type Props = {
userId: string;
@@ -25,7 +22,7 @@ type Props = {
handleShowDirectChannel: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
const ProfilePopoverEdit = ({
const ProfilePopoverSelfUserRow = ({
userId,
currentUserId,
haveOverrideProp,
@@ -51,59 +48,43 @@ const ProfilePopoverEdit = ({
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'
className='user-popover__bottom-row-container'
>
<button
id='editProfileButton'
type='button'
className='btn'
className='btn btn-primary btn-sm'
onClick={handleEditAccountSettings}
>
<AccountOutlineIcon
size={16}
aria-label={formatMessage({
id: 'generic_icons.edit',
defaultMessage: 'Edit Icon',
})}
<i
className='icon icon-account-outline'
aria-hidden='true'
/>
<FormattedMessage
id='user_profile.account.editProfile'
defaultMessage='Edit Profile'
/>
</button>
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
<WithTooltip
id='user_profile.send.dm.yourself'
title={formatMessage({id: 'user_profile.send.dm.yourself', defaultMessage: 'Send yourself a message'})}
placement='top'
overlay={sendMessageTooltip}
>
<button
type='button'
className='btn icon-btn'
className='btn btn-icon btn-sm'
onClick={handleShowDirectChannel}
aria-label={formatMessage({id: 'user_profile.send.dm.yourself', defaultMessage: 'Send yourself a message'})}
>
<SendIcon
size={18}
aria-label={formatMessage({
id: 'user_profile.send.dm.icon',
defaultMessage: 'Send Message Icon',
})}
<i
className='icon icon-send'
aria-hidden='true'
/>
</button>
</OverlayTrigger>
</WithTooltip>
</div>
);
};
export default ProfilePopoverEdit;
export default ProfilePopoverSelfUserRow;

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

@@ -3,7 +3,7 @@
import {DateTime, Duration} from 'luxon';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useIntl} from 'react-intl';
import type {UserTimezone} from '@mattermost/types/users';
@@ -17,10 +17,16 @@ type ProfileTimezoneProps = {
haveOverrideProp: boolean;
}
const returnTimeDiff = (
currentUserTimezone: string | undefined | null,
profileUserTimezone: string,
const TimeZoneDifference = ({
currentUserTimezone,
profileUserTimezone,
}: {
currentUserTimezone: string | undefined | null;
profileUserTimezone: string;
},
) => {
const {formatMessage} = useIntl();
if (!currentUserTimezone) {
return null;
}
@@ -32,23 +38,33 @@ const returnTimeDiff = (
});
if (!offset.valueOf()) {
return undefined;
return null;
}
const timeOffset = offset.toHuman({unitDisplay: 'short', signDisplay: 'never'});
return offset.valueOf() > 0 ? (
<FormattedMessage
id='user_profile.account.hoursAhead'
defaultMessage='({timeOffset} ahead)'
values={{timeOffset}}
/>
<>
{formatMessage(
{
id: 'user_profile.account.hoursAhead',
defaultMessage: '({timeOffset} ahead)',
},
{timeOffset},
)}
</>
) : (
<FormattedMessage
id='user_profile.account.hoursBehind'
defaultMessage='({timeOffset} behind)'
values={{timeOffset}}
/>
<>
{
formatMessage(
{
id: 'user_profile.account.hoursBehind',
defaultMessage: '({timeOffset} behind)',
},
{timeOffset},
)
}
</>
);
};
@@ -57,6 +73,8 @@ const ProfileTimezone = ({
profileUserTimezone,
haveOverrideProp,
}: ProfileTimezoneProps) => {
const {formatMessage} = useIntl();
if (haveOverrideProp || !profileUserTimezone) {
return null;
}
@@ -68,24 +86,27 @@ const ProfileTimezone = ({
<div
className='user-popover__time-status-container'
>
<span className='user-popover__subtitle'>
{profileTimezoneShort ? (
<FormattedMessage
id='user_profile.account.localTimeWithTimezone'
defaultMessage='Local Time ({timezone})'
values={{
timezone: profileTimezoneShort,
}}
/>
) : (
<FormattedMessage
id='user_profile.account.localTime'
defaultMessage='Local Time'
/>
)}
</span>
<span>
<strong
id='user-popover__timezone-title'
className='user-popover__subtitle'
>
{profileTimezoneShort ? formatMessage(
{
id: 'user_profile.account.localTimeWithTimezone',
defaultMessage: 'Local Time ({timezone})',
},
{
timezone: profileTimezoneShort,
},
) : formatMessage({
id: 'user_profile.account.localTime',
defaultMessage: 'Local Time',
})}
</strong>
<p
aria-labelledby='user-popover__timezone-title'
className='user-popover__subtitle-text'
>
<Timestamp
useRelative={false}
useDate={false}
@@ -96,9 +117,11 @@ const ProfileTimezone = ({
}}
/>
{' '}
{returnTimeDiff(currentUserTimezone, profileTimezone)}
</span>
<TimeZoneDifference
currentUserTimezone={currentUserTimezone}
profileUserTimezone={profileTimezone}
/>
</p>
</div>
);
};

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

@@ -1,13 +1,10 @@
// 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 React, {useEffect, useRef} 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';
@@ -18,17 +15,18 @@ 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 {A11yFocusEventDetail} from 'utils/constants';
import {A11yCustomEventTypes} from 'utils/constants';
import type {GlobalState} from 'types/store';
type Props = {
isBot: boolean;
isBot?: boolean;
roles: string;
username: string;
returnFocus: () => void;
hide?: () => void;
userId: string;
channelId?: string;
closeButtonRef: React.RefObject<HTMLButtonElement>;
}
function getIsTeamAdmin(state: GlobalState, userId: string) {
@@ -55,22 +53,34 @@ function getIsChannelAdmin(state: GlobalState, userId: string, channelId?: strin
const ProfilePopoverTitle = ({
isBot,
roles,
username,
returnFocus,
hide,
userId,
channelId,
closeButtonRef,
}: Props) => {
const {formatMessage} = useIntl();
const closeRef = useRef<HTMLButtonElement>(null);
const isTeamAdmin = useSelector((state: GlobalState) => getIsTeamAdmin(state, userId));
const isChannelAdmin = useSelector((state: GlobalState) => getIsChannelAdmin(state, userId, channelId));
const handleClose = useCallback(() => {
useEffect(() => {
// Focus the close button when the popover first opens
document.dispatchEvent(new CustomEvent<A11yFocusEventDetail>(
A11yCustomEventTypes.FOCUS, {
detail: {
target: closeRef.current,
keyboardOnly: true,
},
},
));
}, []);
function handleClose() {
hide?.();
returnFocus();
}, [hide, returnFocus]);
}
let roleTitle;
if (isBot) {
@@ -93,7 +103,7 @@ const ProfilePopoverTitle = ({
className='user-popover__role'
size={'sm'}
text={formatMessage({
id: 'admin.permissions.roles.system_admin.name',
id: 'user_profile.roleTitle.system_admin',
defaultMessage: 'System Admin',
})}
/>
@@ -104,7 +114,7 @@ const ProfilePopoverTitle = ({
className='user-popover__role'
size={'sm'}
text={formatMessage({
id: 'admin.permissions.roles.team_admin.name',
id: 'user_profile.roleTitle.team_admin',
defaultMessage: 'Team Admin',
})}
/>
@@ -115,28 +125,24 @@ const ProfilePopoverTitle = ({
className='user-popover__role'
size={'sm'}
text={formatMessage({
id: 'admin.permissions.roles.channel_admin.name',
id: 'user_profile.roleTitle.channel_admin',
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 className='user-profile-popover-title'>
{roleTitle}
<button
ref={closeRef}
className='btn btn-icon btn-sm closeButtonRelativePosition'
onClick={handleClose}
aria-label={formatMessage({id: 'user_profile.close', defaultMessage: 'Close user profile popover'})}
>
<i className='icon icon-close'/>
</button>
</div>
);
};

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

@@ -1,7 +1,6 @@
// 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 = {
@@ -9,19 +8,21 @@ type Props = {
username: string;
}
const UserName = ({
const ProfilePopoverUserName = ({
hasFullName,
username,
}: Props) => {
const userNameClass = classNames('overflow--ellipsis pb-1', {'user-profile-popover__heading': !hasFullName});
return (
<div
<p
id='userPopoverUsername'
className={userNameClass}
className={
hasFullName ? 'user-profile-popover__non-heading' : 'user-profile-popover__heading'
}
title={username}
>
{`@${username}`}
</div>
</p>
);
};
export default UserName;
export default ProfilePopoverUserName;

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

@@ -24,10 +24,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_direct_channel should match
channelLeaveHandler={[Function]}
icon={
<ProfilePicture
hasMention={false}
isEmoji={false}
newStatusIcon={true}
popoverPlacement="right"
size="xs"
src="/api/v4/users/user_id/image"
statusClass="DirectChannel__status-icon "
@@ -64,10 +61,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_direct_channel should match
channelLeaveHandler={[Function]}
icon={
<ProfilePicture
hasMention={false}
isEmoji={false}
newStatusIcon={true}
popoverPlacement="right"
size="xs"
src="/api/v4/users/user_id/image"
status=""
@@ -105,10 +99,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_direct_channel should match
channelLeaveHandler={[Function]}
icon={
<ProfilePicture
hasMention={false}
isEmoji={false}
newStatusIcon={true}
popoverPlacement="right"
size="xs"
src="/api/v4/users/user_id/image"
statusClass="DirectChannel__status-icon "

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

@@ -59,7 +59,6 @@ exports[`at mention suggestion Should display nick name of non signed in user 1`
loading="lazy"
onError={[Function]}
src="/api/v4/users/userid2/image?_=0"
tabIndex={0}
/>
</Memo(Avatar)>
</span>
@@ -155,7 +154,6 @@ exports[`at mention suggestion Should not display nick name of the signed in use
loading="lazy"
onError={[Function]}
src="/api/v4/users/userid1/image?_=0"
tabIndex={0}
/>
</Memo(Avatar)>
</span>

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

@@ -151,7 +151,6 @@ const AtMentionSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<Ite
<SharedUserIndicator
id={`sharedUserIndicator-${item.id}`}
className='shared-user-icon'
withTooltip={true}
/>
) : null;

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

@@ -37,7 +37,6 @@ const SearchUserSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<Us
<SharedUserIndicator
id={`sharedUserIndicator-${item.id}`}
className='mention__shared-user-icon'
withTooltip={true}
/>
);
}

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

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

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

@@ -6,3 +6,6 @@ export enum Load {
LOADING,
FAILED,
}
export const USER_GROUP_POPOVER_OPENING_DELAY = 300;
export const USER_GROUP_POPOVER_CLOSING_DELAY = 500;

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

@@ -385,49 +385,60 @@ exports[`component/user_group_popover/group_member_list should match snapshot 1`
}
}
>
<UserButton
aria-haspopup="dialog"
onClick={[Function]}
<ProfilePopoverController
hideStatus={false}
src="/api/v4/users/id0/image?_=0"
userId="id0"
>
<button
<span
aria-expanded="false"
aria-haspopup="dialog"
className="UserButton-bZNeGd lLsMM"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onPointerDown={[Function]}
>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
tabIndex={-1}
url="/api/v4/users/id0/image?_=0"
username="username0"
>
<img
alt="username0 profile image"
className="Avatar Avatar-sm avatar-post-preview"
loading="lazy"
onError={[Function]}
src="/api/v4/users/id0/image?_=0"
tabIndex={-1}
/>
</Memo(Avatar)>
<Username
className="overflow--ellipsis text-nowrap"
>
<span
className="Username-hebqVM cKlgOV overflow--ellipsis text-nowrap"
<UserButton>
<button
className="UserButton-bZNeGd lLsMM"
>
Name0 Surname0
</span>
</Username>
<Gap
className="group-member-list_gap"
>
<span
className="Gap-pVXiI hWeVKL group-member-list_gap"
/>
</Gap>
</button>
</UserButton>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
tabIndex={-1}
url="/api/v4/users/id0/image?_=0"
username="username0"
>
<img
alt="username0 profile image"
className="Avatar Avatar-sm avatar-post-preview"
loading="lazy"
onError={[Function]}
src="/api/v4/users/id0/image?_=0"
tabIndex={-1}
/>
</Memo(Avatar)>
<Username
className="overflow--ellipsis text-nowrap"
>
<span
className="Username-hebqVM cKlgOV overflow--ellipsis text-nowrap"
>
Name0 Surname0
</span>
</Username>
<Gap
className="group-member-list_gap"
>
<span
className="Gap-pVXiI hWeVKL group-member-list_gap"
/>
</Gap>
</button>
</UserButton>
</span>
</ProfilePopoverController>
<DMContainer
className="group-member-list_dm-button"
>
@@ -593,49 +604,60 @@ exports[`component/user_group_popover/group_member_list should match snapshot 1`
}
}
>
<UserButton
aria-haspopup="dialog"
onClick={[Function]}
<ProfilePopoverController
hideStatus={false}
src="/api/v4/users/id1/image?_=0"
userId="id1"
>
<button
<span
aria-expanded="false"
aria-haspopup="dialog"
className="UserButton-bZNeGd lLsMM"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onPointerDown={[Function]}
>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
tabIndex={-1}
url="/api/v4/users/id1/image?_=0"
username="username1"
>
<img
alt="username1 profile image"
className="Avatar Avatar-sm avatar-post-preview"
loading="lazy"
onError={[Function]}
src="/api/v4/users/id1/image?_=0"
tabIndex={-1}
/>
</Memo(Avatar)>
<Username
className="overflow--ellipsis text-nowrap"
>
<span
className="Username-hebqVM cKlgOV overflow--ellipsis text-nowrap"
<UserButton>
<button
className="UserButton-bZNeGd lLsMM"
>
Name1 Surname1
</span>
</Username>
<Gap
className="group-member-list_gap"
>
<span
className="Gap-pVXiI hWeVKL group-member-list_gap"
/>
</Gap>
</button>
</UserButton>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
tabIndex={-1}
url="/api/v4/users/id1/image?_=0"
username="username1"
>
<img
alt="username1 profile image"
className="Avatar Avatar-sm avatar-post-preview"
loading="lazy"
onError={[Function]}
src="/api/v4/users/id1/image?_=0"
tabIndex={-1}
/>
</Memo(Avatar)>
<Username
className="overflow--ellipsis text-nowrap"
>
<span
className="Username-hebqVM cKlgOV overflow--ellipsis text-nowrap"
>
Name1 Surname1
</span>
</Username>
<Gap
className="group-member-list_gap"
>
<span
className="Gap-pVXiI hWeVKL group-member-list_gap"
/>
</Gap>
</button>
</UserButton>
</span>
</ProfilePopoverController>
<DMContainer
className="group-member-list_dm-button"
>
@@ -801,49 +823,60 @@ exports[`component/user_group_popover/group_member_list should match snapshot 1`
}
}
>
<UserButton
aria-haspopup="dialog"
onClick={[Function]}
<ProfilePopoverController
hideStatus={false}
src="/api/v4/users/id2/image?_=0"
userId="id2"
>
<button
<span
aria-expanded="false"
aria-haspopup="dialog"
className="UserButton-bZNeGd lLsMM"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onPointerDown={[Function]}
>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
tabIndex={-1}
url="/api/v4/users/id2/image?_=0"
username="username2"
>
<img
alt="username2 profile image"
className="Avatar Avatar-sm avatar-post-preview"
loading="lazy"
onError={[Function]}
src="/api/v4/users/id2/image?_=0"
tabIndex={-1}
/>
</Memo(Avatar)>
<Username
className="overflow--ellipsis text-nowrap"
>
<span
className="Username-hebqVM cKlgOV overflow--ellipsis text-nowrap"
<UserButton>
<button
className="UserButton-bZNeGd lLsMM"
>
Name2 Surname2
</span>
</Username>
<Gap
className="group-member-list_gap"
>
<span
className="Gap-pVXiI hWeVKL group-member-list_gap"
/>
</Gap>
</button>
</UserButton>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
tabIndex={-1}
url="/api/v4/users/id2/image?_=0"
username="username2"
>
<img
alt="username2 profile image"
className="Avatar Avatar-sm avatar-post-preview"
loading="lazy"
onError={[Function]}
src="/api/v4/users/id2/image?_=0"
tabIndex={-1}
/>
</Memo(Avatar)>
<Username
className="overflow--ellipsis text-nowrap"
>
<span
className="Username-hebqVM cKlgOV overflow--ellipsis text-nowrap"
>
Name2 Surname2
</span>
</Username>
<Gap
className="group-member-list_gap"
>
<span
className="Gap-pVXiI hWeVKL group-member-list_gap"
/>
</Gap>
</button>
</UserButton>
</span>
</ProfilePopoverController>
<DMContainer
className="group-member-list_dm-button"
>
@@ -1009,49 +1042,60 @@ exports[`component/user_group_popover/group_member_list should match snapshot 1`
}
}
>
<UserButton
aria-haspopup="dialog"
onClick={[Function]}
<ProfilePopoverController
hideStatus={false}
src="/api/v4/users/id3/image?_=0"
userId="id3"
>
<button
<span
aria-expanded="false"
aria-haspopup="dialog"
className="UserButton-bZNeGd lLsMM"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onPointerDown={[Function]}
>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
tabIndex={-1}
url="/api/v4/users/id3/image?_=0"
username="username3"
>
<img
alt="username3 profile image"
className="Avatar Avatar-sm avatar-post-preview"
loading="lazy"
onError={[Function]}
src="/api/v4/users/id3/image?_=0"
tabIndex={-1}
/>
</Memo(Avatar)>
<Username
className="overflow--ellipsis text-nowrap"
>
<span
className="Username-hebqVM cKlgOV overflow--ellipsis text-nowrap"
<UserButton>
<button
className="UserButton-bZNeGd lLsMM"
>
Name3 Surname3
</span>
</Username>
<Gap
className="group-member-list_gap"
>
<span
className="Gap-pVXiI hWeVKL group-member-list_gap"
/>
</Gap>
</button>
</UserButton>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
tabIndex={-1}
url="/api/v4/users/id3/image?_=0"
username="username3"
>
<img
alt="username3 profile image"
className="Avatar Avatar-sm avatar-post-preview"
loading="lazy"
onError={[Function]}
src="/api/v4/users/id3/image?_=0"
tabIndex={-1}
/>
</Memo(Avatar)>
<Username
className="overflow--ellipsis text-nowrap"
>
<span
className="Username-hebqVM cKlgOV overflow--ellipsis text-nowrap"
>
Name3 Surname3
</span>
</Username>
<Gap
className="group-member-list_gap"
>
<span
className="Gap-pVXiI hWeVKL group-member-list_gap"
/>
</Gap>
</button>
</UserButton>
</span>
</ProfilePopoverController>
<DMContainer
className="group-member-list_dm-button"
>
@@ -1217,49 +1261,60 @@ exports[`component/user_group_popover/group_member_list should match snapshot 1`
}
}
>
<UserButton
aria-haspopup="dialog"
onClick={[Function]}
<ProfilePopoverController
hideStatus={false}
src="/api/v4/users/id4/image?_=0"
userId="id4"
>
<button
<span
aria-expanded="false"
aria-haspopup="dialog"
className="UserButton-bZNeGd lLsMM"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onPointerDown={[Function]}
>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
tabIndex={-1}
url="/api/v4/users/id4/image?_=0"
username="username4"
>
<img
alt="username4 profile image"
className="Avatar Avatar-sm avatar-post-preview"
loading="lazy"
onError={[Function]}
src="/api/v4/users/id4/image?_=0"
tabIndex={-1}
/>
</Memo(Avatar)>
<Username
className="overflow--ellipsis text-nowrap"
>
<span
className="Username-hebqVM cKlgOV overflow--ellipsis text-nowrap"
<UserButton>
<button
className="UserButton-bZNeGd lLsMM"
>
Name4 Surname4
</span>
</Username>
<Gap
className="group-member-list_gap"
>
<span
className="Gap-pVXiI hWeVKL group-member-list_gap"
/>
</Gap>
</button>
</UserButton>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
tabIndex={-1}
url="/api/v4/users/id4/image?_=0"
username="username4"
>
<img
alt="username4 profile image"
className="Avatar Avatar-sm avatar-post-preview"
loading="lazy"
onError={[Function]}
src="/api/v4/users/id4/image?_=0"
tabIndex={-1}
/>
</Memo(Avatar)>
<Username
className="overflow--ellipsis text-nowrap"
>
<span
className="Username-hebqVM cKlgOV overflow--ellipsis text-nowrap"
>
Name4 Surname4
</span>
</Username>
<Gap
className="group-member-list_gap"
>
<span
className="Gap-pVXiI hWeVKL group-member-list_gap"
/>
</Gap>
</button>
</UserButton>
</span>
</ProfilePopoverController>
<DMContainer
className="group-member-list_dm-button"
>

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

@@ -17,6 +17,7 @@ import type {ActionResult} from 'mattermost-redux/types/actions';
import NoResultsIndicator from 'components/no_results_indicator';
import {NoResultsVariant} from 'components/no_results_indicator/types';
import ProfilePopover from 'components/profile_popover';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import SimpleTooltip from 'components/widgets/simple_tooltip';
import Avatar from 'components/widgets/users/avatar';
@@ -55,11 +56,6 @@ export type Props = {
*/
hide: () => void;
/**
* Function to call to show a profile popover and hide parent popover
*/
showUserOverlay: (user: UserProfile) => void;
/**
* State of current search
*/
@@ -88,7 +84,6 @@ const GroupMemberList = (props: Props) => {
teamUrl,
searchTerm,
searchState,
showUserOverlay,
} = props;
const history = useHistory();
@@ -170,20 +165,23 @@ const GroupMemberList = (props: Props) => {
key={user.id}
role='listitem'
>
<UserButton
onClick={() => showUserOverlay(user)}
aria-haspopup='dialog'
<ProfilePopover
userId={user.id}
src={Utils.imageURLForUser(user?.id ?? '')}
hideStatus={user.is_bot}
>
<Avatar
username={user.username}
size={'sm'}
url={Utils.imageURLForUser(user?.id ?? '')}
className={'avatar-post-preview'}
tabIndex={-1}
/>
<Username className='overflow--ellipsis text-nowrap'>{name}</Username>
<Gap className='group-member-list_gap'/>
</UserButton>
<UserButton>
<Avatar
username={user.username}
size={'sm'}
url={Utils.imageURLForUser(user?.id ?? '')}
className={'avatar-post-preview'}
tabIndex={-1}
/>
<Username className='overflow--ellipsis text-nowrap'>{name}</Username>
<Gap className='group-member-list_gap'/>
</UserButton>
</ProfilePopover>
<DMContainer className='group-member-list_dm-button'>
<SimpleTooltip
id={`name-${user.id}`}

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

@@ -1,35 +1,6 @@
// 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 {UserGroupPopoverController} from './user_group_popover_controller';
import {searchProfiles} from 'mattermost-redux/actions/users';
import {openModal} from 'actions/views/modals';
import {setPopoverSearchTerm} from 'actions/views/search';
import {getIsMobileView} from 'selectors/views/browser';
import type {GlobalState} from 'types/store';
import UserGroupPopover from './user_group_popover';
function mapStateToProps(state: GlobalState) {
return {
searchTerm: state.views.search.popoverSearch,
isMobileView: getIsMobileView(state),
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
setPopoverSearchTerm,
openModal,
searchProfiles,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(UserGroupPopover);
export default UserGroupPopoverController;

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

@@ -1,25 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {useSelector} from 'react-redux';
import {isAnyModalOpen} from 'selectors/views/modals';
export default function useShouldClose(): boolean {
const [shouldClose, setShouldClose] = useState(false);
const [initialHasOpenModals, setInitialHasOpenModals] = useState(false);
const hasOpenModals = useSelector(isAnyModalOpen);
useEffect(() => {
setInitialHasOpenModals(hasOpenModals);
}, []);
useEffect(() => {
if (initialHasOpenModals !== hasOpenModals) {
setShouldClose(true);
}
}, [initialHasOpenModals, hasOpenModals]);
return shouldClose;
}

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

@@ -1,3 +1,8 @@
#user-group-popover .popover-content {
padding: 0;
}
.user-group-popover-floating-overlay {
// 99 being the z-index of the global header
z-index: 1060;
}

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

@@ -102,16 +102,9 @@ describe('component/user_group_popover', () => {
};
const baseProps: ComponentProps<typeof UserGroupPopover> = {
searchTerm: '',
group: group1,
showUserOverlay: jest.fn(),
hide: jest.fn(),
returnFocus: jest.fn(),
actions: {
setPopoverSearchTerm: jest.fn(),
openModal: jest.fn(),
searchProfiles: jest.fn().mockImplementation(() => Promise.resolve()),
},
};
test('should match snapshot', async () => {
@@ -129,25 +122,6 @@ describe('component/user_group_popover', () => {
expect(wrapper).toMatchSnapshot();
});
test('should open modal', async () => {
const store = await mockStore(initialState);
const wrapper = mountWithIntl(
<Provider store={store}>
<BrowserRouter>
<UserGroupPopover
{...baseProps}
/>
</BrowserRouter>
</Provider>,
);
await actImmediate(wrapper);
expect(wrapper.find('button.user-group-popover_header-button').exists()).toBe(true);
wrapper.find('button.user-group-popover_header-button').simulate('click');
expect(baseProps.actions.openModal).toBeCalled();
expect(baseProps.hide).toBeCalled();
});
test('should not show search bar', async () => {
const store = await mockStore(initialState);
const wrapper = mountWithIntl(
@@ -165,24 +139,6 @@ describe('component/user_group_popover', () => {
expect(wrapper.find('.user-group-popover_search-bar').exists()).toBe(false);
});
test('should show and set search term', async () => {
const store = await mockStore(initialState);
const wrapper = mountWithIntl(
<Provider store={store}>
<BrowserRouter>
<UserGroupPopover
{...baseProps}
/>
</BrowserRouter>
</Provider>,
);
await actImmediate(wrapper);
expect(wrapper.find('.user-group-popover_search-bar input').exists()).toBe(true);
wrapper.find('.user-group-popover_search-bar input').simulate('change', {target: {value: 'a'}});
expect(baseProps.actions.setPopoverSearchTerm).toHaveBeenCalledWith('a');
});
test('should show users', async () => {
const store = await mockStore(initialState);
const wrapper = mountWithIntl(

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

@@ -2,97 +2,62 @@
// See LICENSE.txt for license information.
import debounce from 'lodash/debounce';
import type {ChangeEvent} from 'react';
import React, {useEffect, useCallback, useState, useRef} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import styled from 'styled-components';
import {MagnifyIcon} from '@mattermost/compass-icons/components';
import type {Group} from '@mattermost/types/groups';
import type {UserProfile} from '@mattermost/types/users';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {searchProfiles} from 'mattermost-redux/actions/users';
import {openModal} from 'actions/views/modals';
import {setPopoverSearchTerm} from 'actions/views/search';
import {QuickInput} from 'components/quick_input/quick_input';
import GroupMemberList from 'components/user_group_popover/group_member_list';
import UserGroupsModal from 'components/user_groups_modal';
import ViewUserGroupModal from 'components/view_user_group_modal';
import Popover from 'components/widgets/popover';
import Constants, {A11yClassNames, A11yCustomEventTypes, ModalIdentifiers} from 'utils/constants';
import Constants, {A11yCustomEventTypes, ModalIdentifiers} from 'utils/constants';
import type {A11yFocusEventDetail} from 'utils/constants';
import * as Keyboard from 'utils/keyboard';
import {shouldFocusMainTextbox} from 'utils/post_utils';
import type {ModalData} from 'types/actions';
import type {GlobalState} from 'types/store';
import {Load} from './constants';
import useShouldClose from './useShouldClose';
import './user_group_popover.scss';
export type Props = {
/**
* The group corresponding to the parent popover
*/
group: Group;
/**
* Function to call if parent popover should be hidden
*/
hide: () => void;
/**
* Function to call if focus should be returned to triggering element
*/
returnFocus: () => void;
/**
* Function to call to show a profile popover and hide parent popover
*/
showUserOverlay: (user: UserProfile) => void;
/**
* @internal
*/
searchTerm: string;
actions: {
setPopoverSearchTerm: (term: string) => void;
searchProfiles: (term: string, options: any) => Promise<ActionResult>;
openModal: <P>(modalData: ModalData<P>) => void;
};
}
const UserGroupPopover = ({
actions,
group,
hide,
returnFocus,
searchTerm,
showUserOverlay,
// These props are not passed explictly to this component, but
// they are added when this component is passed as a child to Overlay.
// They are not typed in the component because they will cause more confusion.
...popoverProps
}: Props) => {
const {formatMessage} = useIntl();
const closeRef = useRef<HTMLButtonElement>(null);
const dispatch = useDispatch();
const searchTerm = useSelector((state: GlobalState) => state.views.search.popoverSearch);
const [searchState, setSearchState] = useState(Load.DONE);
const shouldClose = useShouldClose();
const doSearch = useCallback(debounce(async (term) => {
const res = await actions.searchProfiles(term, {in_group_id: group.id});
const res = await dispatch(searchProfiles(term, {in_group_id: group.id}));
if (res.data) {
setSearchState(Load.DONE);
} else {
setSearchState(Load.FAILED);
}
}, Constants.SEARCH_TIMEOUT_MILLISECONDS), [actions.searchProfiles]);
}, Constants.SEARCH_TIMEOUT_MILLISECONDS), []);
useEffect(() => {
// Focus the close button when the popover first opens
@@ -107,9 +72,9 @@ const UserGroupPopover = ({
// Unset the popover search term on mount and unmount
// This is to prevent some odd rendering issues when quickly opening and closing popovers
actions.setPopoverSearchTerm('');
dispatch(setPopoverSearchTerm(''));
return () => {
actions.setPopoverSearchTerm('');
dispatch(setPopoverSearchTerm(''));
};
}, []);
@@ -123,26 +88,20 @@ const UserGroupPopover = ({
}
}, [searchTerm, doSearch]);
useEffect(() => {
if (shouldClose) {
hide();
}
}, [hide, shouldClose]);
const openGroupsModal = () => {
actions.openModal({
dispatch(openModal({
modalId: ModalIdentifiers.USER_GROUPS,
dialogType: UserGroupsModal,
dialogProps: {
backButtonAction: openGroupsModal,
onExited: returnFocus,
},
});
}));
};
const openViewGroupModal = () => {
hide();
actions.openModal({
dispatch(openModal({
modalId: ModalIdentifiers.VIEW_USER_GROUP,
dialogType: ViewUserGroupModal,
dialogProps: {
@@ -151,7 +110,7 @@ const UserGroupPopover = ({
backButtonAction: openViewGroupModal,
onExited: returnFocus,
},
});
}));
};
const handleClose = () => {
@@ -159,107 +118,81 @@ const UserGroupPopover = ({
returnFocus();
};
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
actions.setPopoverSearchTerm(event.target.value);
const handleSearch = (event: ChangeEvent<HTMLInputElement>) => {
dispatch(setPopoverSearchTerm(event.target.value));
};
const handleClear = () => {
actions.setPopoverSearchTerm('');
dispatch(setPopoverSearchTerm(''));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (shouldFocusMainTextbox(e, document.activeElement)) {
hide();
} else if (Keyboard.isKeyPressed(e, Constants.KeyCodes.ESCAPE)) {
returnFocus();
}
};
const tabCatcher = (
<span
tabIndex={0}
onFocus={(e) => {
(e.relatedTarget as HTMLElement)?.focus();
}}
/>
);
return (
<Popover
id='user-group-popover'
{...popoverProps}
>
{tabCatcher}
<Body
role='dialog'
aria-modal={true}
onKeyDown={handleKeyDown}
className={A11yClassNames.POPUP}
aria-label={group.display_name}
>
<Header>
<Heading>
<Title
className='overflow--ellipsis text-nowrap'
>
{group.display_name}
</Title>
<CloseButton
className='btn btn-sm btn-compact btn-icon'
aria-label={formatMessage({id: 'user_group_popover.close', defaultMessage: 'Close'})}
onClick={handleClose}
ref={closeRef}
>
<i
className='icon icon-close'
/>
</CloseButton>
</Heading>
<Subtitle>
<span className='overflow--ellipsis text-nowrap'>{'@'}{group.name}</span>
<Dot>{'•'}</Dot>
<FormattedMessage
id='user_group_popover.memberCount'
defaultMessage='{member_count} {member_count, plural, one {Member} other {Members}}'
values={{
member_count: group.member_count,
}}
tagName={NoShrink}
<Body>
<Header>
<Heading>
<Title
className='overflow--ellipsis text-nowrap'
>
{group.display_name}
</Title>
<CloseButton
className='btn btn-sm btn-compact btn-icon'
aria-label={formatMessage({id: 'user_group_popover.close', defaultMessage: 'Close user group popover'})}
onClick={handleClose}
ref={closeRef}
>
<i
className='icon icon-close'
/>
</Subtitle>
<HeaderButton
aria-label={`${group.display_name} @${group.name} ${formatMessage({id: 'user_group_popover.memberCount', defaultMessage: '{member_count} {member_count, plural, one {Member} other {Members}}'}, {member_count: group.member_count})} ${formatMessage({id: 'user_group_popover.openGroupModal', defaultMessage: 'View full group info'})}`}
onClick={openViewGroupModal}
className='user-group-popover_header-button'
</CloseButton>
</Heading>
<Subtitle>
<span className='overflow--ellipsis text-nowrap'>{'@'}{group.name}</span>
<Dot>{'•'}</Dot>
<FormattedMessage
id='user_group_popover.memberCount'
defaultMessage='{member_count} {member_count, plural, one {Member} other {Members}}'
values={{
member_count: group.member_count,
}}
tagName={NoShrink}
/>
</Header>
{group.member_count > 10 ? (
<SearchBar>
<MagnifyIcon/>
<QuickInput
type='text'
className='user-group-popover_search-bar'
placeholder={formatMessage({id: 'user_group_popover.searchGroupMembers', defaultMessage: 'Search members'})}
value={searchTerm}
onChange={handleSearch}
clearable={true}
onClear={handleClear}
/>
</SearchBar>
) : null}
<GroupMemberList
group={group}
hide={hide}
searchState={searchState}
showUserOverlay={showUserOverlay}
</Subtitle>
<HeaderButton
aria-label={`${group.display_name} @${group.name} ${formatMessage({id: 'user_group_popover.memberCount', defaultMessage: '{member_count} {member_count, plural, one {Member} other {Members}}'}, {member_count: group.member_count})} ${formatMessage({id: 'user_group_popover.openGroupModal', defaultMessage: 'View full group info'})}`}
onClick={openViewGroupModal}
className='user-group-popover_header-button'
/>
</Body>
{tabCatcher}
</Popover>);
</Header>
{group.member_count > 10 ? (
<SearchBar>
<MagnifyIcon/>
<QuickInput
type='text'
className='user-group-popover_search-bar'
placeholder={formatMessage({id: 'user_group_popover.searchGroupMembers', defaultMessage: 'Search members'})}
value={searchTerm}
onChange={handleSearch}
clearable={true}
onClear={handleClear}
/>
</SearchBar>
) : null}
<GroupMemberList
group={group}
hide={hide}
searchState={searchState}
/>
</Body>
);
};
const Body = styled.div`
width: 264px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
background: var(--center-channel-bg);
box-shadow: var(--elevation-4);
`;
const Header = styled.div`

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

@@ -0,0 +1,112 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
useFloating,
autoUpdate,
autoPlacement,
useTransitionStyles,
useClick,
useDismiss,
useInteractions,
useRole,
FloatingFocusManager,
FloatingOverlay,
FloatingPortal,
} from '@floating-ui/react';
import type {ReactNode} from 'react';
import React, {useCallback, useState} from 'react';
import type {Group} from '@mattermost/types/groups';
import {A11yClassNames} from 'utils/constants';
import {USER_GROUP_POPOVER_CLOSING_DELAY, USER_GROUP_POPOVER_OPENING_DELAY} from './constants';
import UserGroupPopover from './user_group_popover';
interface Props {
children: ReactNode;
/**
* The group corresponding to the parent popover
*/
group: Group;
/**
* Function to call if focus should be returned to triggering element
*/
returnFocus: () => void;
}
export function UserGroupPopoverController(props: Props) {
const [isOpen, setOpen] = useState(false);
const {refs, floatingStyles, context: floatingContext} = useFloating({
open: isOpen,
onOpenChange: setOpen,
whileElementsMounted: autoUpdate,
middleware: [autoPlacement()],
});
const {isMounted, styles: transitionStyles} = useTransitionStyles(floatingContext, {
duration: {
open: USER_GROUP_POPOVER_OPENING_DELAY,
close: USER_GROUP_POPOVER_CLOSING_DELAY,
},
});
const combinedFloatingStyles = Object.assign({}, floatingStyles, transitionStyles);
const clickInteractions = useClick(floatingContext);
const dismissInteraction = useDismiss(floatingContext);
const role = useRole(floatingContext);
const {getReferenceProps, getFloatingProps} = useInteractions([
clickInteractions,
dismissInteraction,
role,
]);
const handleHide = useCallback(() => {
setOpen(false);
}, []);
return (
<>
<span
ref={refs.setReference}
{...getReferenceProps()}
>
{props.children}
</span>
{isMounted && (
<FloatingPortal id='user-group-popover-portal'>
<FloatingOverlay
id='user-group-popover-floating-overlay'
className='user-group-popover-floating-overlay'
lockScroll={true}
>
<FloatingFocusManager context={floatingContext}>
<div
ref={refs.setFloating}
style={combinedFloatingStyles}
className={A11yClassNames.POPUP}
{...getFloatingProps()}
>
<UserGroupPopover
group={props.group}
returnFocus={props.returnFocus}
hide={handleHide}
/>
</div>
</FloatingFocusManager>
</FloatingOverlay>
</FloatingPortal>
)}
</>
);
}

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

@@ -116,7 +116,6 @@ const UserListRow = ({user, status, extraInfo = [], actions = [], actionProps, a
status={statusProp}
size='md'
userId={user.id}
hasMention={true}
username={user.username}
/>
<div

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

@@ -2,65 +2,31 @@
exports[`components/UserProfile should match snapshot 1`] = `
<Fragment>
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
hideStatus={false}
overwriteName=""
src="/api/v4/users/undefined/image?_=0"
userId="user_id"
/>
}
placement="right"
rootClose={true}
trigger={
Array [
"click",
]
}
<ProfilePopoverController
hideStatus={false}
overwriteName=""
src="/api/v4/users/undefined/image?_=0"
triggerComponentAs="button"
triggerComponentClass="user-popover style--none"
userId="user_id"
>
<button
aria-label="nickname"
className="user-popover style--none"
>
nickname
</button>
</OverlayTrigger>
nickname
</ProfilePopoverController>
</Fragment>
`;
exports[`components/UserProfile should match snapshot, when displayUsername is enabled 1`] = `
<Fragment>
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
hideStatus={false}
overwriteName=""
src="/api/v4/users/undefined/image?_=0"
userId="user_id"
/>
}
placement="right"
rootClose={true}
trigger={
Array [
"click",
]
}
<ProfilePopoverController
hideStatus={false}
overwriteName=""
src="/api/v4/users/undefined/image?_=0"
triggerComponentAs="button"
triggerComponentClass="user-popover style--none"
userId="user_id"
>
<button
aria-label="@username"
className="user-popover style--none"
>
@username
</button>
</OverlayTrigger>
@username
</ProfilePopoverController>
</Fragment>
`;
@@ -74,74 +40,39 @@ exports[`components/UserProfile should match snapshot, when popover is disabled
exports[`components/UserProfile should match snapshot, when user is shared 1`] = `
<Fragment>
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
hideStatus={false}
overwriteName=""
src="/api/v4/users/undefined/image?_=0"
userId="user_id"
/>
}
placement="right"
rootClose={true}
trigger={
Array [
"click",
]
}
<ProfilePopoverController
hideStatus={false}
overwriteName=""
src="/api/v4/users/undefined/image?_=0"
triggerComponentAs="button"
triggerComponentClass="user-popover style--none"
userId="user_id"
>
<button
aria-label="nickname"
className="user-popover style--none"
>
nickname
</button>
</OverlayTrigger>
nickname
</ProfilePopoverController>
<SharedUserIndicator
className="shared-user-icon"
id="sharedUserIndicator-user_id"
withTooltip={true}
/>
</Fragment>
`;
exports[`components/UserProfile should match snapshot, with colorization 1`] = `
<Fragment>
<OverlayTrigger
defaultOverlayShown={false}
overlay={
<Memo(ProfilePopover)
className="user-profile-popover"
hide={[Function]}
hideStatus={false}
overwriteName=""
src="/api/v4/users/undefined/image?_=0"
userId="user_id"
/>
}
placement="right"
rootClose={true}
trigger={
Array [
"click",
]
}
>
<button
aria-label="nickname"
className="user-popover style--none"
style={
Object {
"color": "#bbd279",
}
<ProfilePopoverController
hideStatus={false}
overwriteName=""
src="/api/v4/users/undefined/image?_=0"
triggerComponentAs="button"
triggerComponentClass="user-popover style--none"
triggerComponentStyle={
Object {
"color": "#bbd279",
}
>
nickname
</button>
</OverlayTrigger>
}
userId="user_id"
>
nickname
</ProfilePopoverController>
</Fragment>
`;

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

@@ -1,19 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ConnectedProps} from 'react-redux';
import {connect} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile as UserProfileType} from '@mattermost/types/users';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getUser, makeGetDisplayName} from 'mattermost-redux/selectors/entities/users';
import {getIsMobileView} from 'selectors/views/browser';
import type {GlobalState} from 'types/store';
import UserProfile from './user_profile';
type OwnProps = {
userId: string;
export type OwnProps = {
userId: UserProfileType['id'];
overwriteName?: string;
overwriteIcon?: string;
disablePopover?: boolean;
displayUsername?: boolean;
colorize?: boolean;
hideStatus?: boolean;
channelId?: Channel['id'];
}
function makeMapStateToProps() {
@@ -27,10 +36,13 @@ function makeMapStateToProps() {
displayName: getDisplayName(state, ownProps.userId, true),
user,
theme,
isMobileView: getIsMobileView(state),
isShared: Boolean(user && user.remote_id),
};
};
}
const connector = connect(makeMapStateToProps);
export type PropsFromRedux = ConnectedProps<typeof connector>;
export default connect(makeMapStateToProps)(UserProfile);

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

@@ -18,6 +18,8 @@ describe('components/UserProfile', () => {
user: {username: 'username'} as UserProfileType,
userId: 'user_id',
theme: Preferences.THEMES.onyx,
isShared: false,
dispatch: jest.fn(),
};
test('should match snapshot', () => {

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

@@ -1,15 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import type {ReactNode} from 'react';
import React from 'react';
import type {UserProfile as UserProfileType} from '@mattermost/types/users';
import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
import {isGuest} from 'mattermost-redux/utils/user_utils';
import OverlayTrigger from 'components/overlay_trigger';
import type {BaseOverlayTrigger} from 'components/overlay_trigger';
import ProfilePopover from 'components/profile_popover';
import SharedUserIndicator from 'components/shared_user_indicator';
import BotTag from 'components/widgets/tag/bot_tag';
@@ -19,144 +15,80 @@ import {imageURLForUser} from 'utils/utils';
import {generateColor} from './utils';
export type Props = {
user?: UserProfileType;
userId: string;
displayName?: string;
isShared?: boolean;
overwriteName?: string;
overwriteIcon?: string;
disablePopover?: boolean;
displayUsername?: boolean;
colorize?: boolean;
hideStatus?: boolean;
isMobileView: boolean;
isRHS?: boolean;
channelId?: string;
theme?: Theme;
}
import type {OwnProps, PropsFromRedux} from './index';
export default class UserProfile extends PureComponent<Props> {
private overlay?: BaseOverlayTrigger;
export type Props = PropsFromRedux & OwnProps;
static defaultProps: Partial<Props> = {
disablePopover: false,
displayUsername: false,
hideStatus: false,
isRHS: false,
overwriteName: '',
colorize: false,
};
export default function UserProfile({
disablePopover = false,
displayUsername = false,
hideStatus = false,
overwriteName = '',
colorize = false,
user,
displayName,
theme,
userId,
channelId,
overwriteIcon,
isShared,
}: Props) {
let name: ReactNode;
if (user && displayUsername) {
name = `@${(user.username)}`;
} else {
name = overwriteName || displayName || '...';
}
hideProfilePopover = (): void => {
if (this.overlay) {
this.overlay.hide();
}
};
let userColor = theme?.centerChannelColor;
if (user && theme) {
userColor = generateColor(user.username, theme.centerChannelBg);
}
setOverlaynRef = (ref: BaseOverlayTrigger): void => {
this.overlay = ref;
};
render(): React.ReactNode {
const {
disablePopover,
displayName,
displayUsername,
isMobileView,
isRHS,
isShared,
hideStatus,
overwriteName,
overwriteIcon,
user,
userId,
channelId,
colorize,
theme,
} = this.props;
let name: React.ReactNode;
if (user && displayUsername) {
name = `@${(user.username)}`;
} else {
name = overwriteName || displayName || '...';
}
const ariaName: string = typeof name === 'string' ? name.toLowerCase() : '';
let userColor = theme?.centerChannelColor;
if (user && theme) {
userColor = generateColor(user.username, theme.centerChannelBg);
}
let userStyle;
if (colorize) {
userStyle = {color: userColor};
}
if (disablePopover) {
return (
<div
className='user-popover'
style={userStyle}
>{name}</div>
);
}
let placement = 'right';
if (isRHS && !isMobileView) {
placement = 'left';
}
let profileImg = '';
if (user) {
profileImg = imageURLForUser(user.id, user.last_picture_update);
}
let sharedIcon;
if (isShared) {
sharedIcon = (
<SharedUserIndicator
id={`sharedUserIndicator-${userId}`}
className='shared-user-icon'
withTooltip={true}
/>
);
}
let userStyle;
if (colorize) {
userStyle = {color: userColor};
}
if (disablePopover) {
return (
<>
<OverlayTrigger
ref={this.setOverlaynRef}
trigger={['click']}
placement={placement}
rootClose={true}
overlay={
<ProfilePopover
className='user-profile-popover'
userId={userId}
channelId={channelId}
src={profileImg}
hide={this.hideProfilePopover}
hideStatus={hideStatus}
overwriteName={overwriteName}
overwriteIcon={overwriteIcon}
/>
}
>
<button
aria-label={ariaName}
className='user-popover style--none'
style={userStyle}
>
{name}
</button>
</OverlayTrigger>
{sharedIcon}
{(user && user.is_bot) && <BotTag/>}
{(user && isGuest(user.roles)) && <GuestTag/>}
</>
<div
className='user-popover'
style={userStyle}
>
{name}
</div>
);
}
let profileImg = '';
if (user) {
profileImg = imageURLForUser(user.id, user.last_picture_update);
}
return (
<>
<ProfilePopover<HTMLButtonElement>
triggerComponentAs='button'
triggerComponentClass='user-popover style--none'
triggerComponentStyle={userStyle}
userId={userId}
src={profileImg}
channelId={channelId}
hideStatus={hideStatus}
overwriteIcon={overwriteIcon}
overwriteName={overwriteName}
>
{name}
</ProfilePopover>
{(isShared) &&
<SharedUserIndicator
id={`sharedUserIndicator-${userId}`}
className='shared-user-icon'
/>
}
{(user && user.is_bot) && <BotTag/>}
{(user && isGuest(user.roles)) && <GuestTag/>}
</>
);
}

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

@@ -91,6 +91,7 @@
}
.group-member-username {
padding-left: 6px;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 12px;
line-height: 18px;

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

@@ -7,7 +7,6 @@ exports[`components/widgets/users/Avatar should match the snapshot 1`] = `
loading="lazy"
onError={[Function]}
src="test-url"
tabIndex={0}
/>
`;
@@ -25,6 +24,5 @@ exports[`components/widgets/users/Avatar should match the snapshot only with url
loading="lazy"
onError={[Function]}
src="test-url"
tabIndex={0}
/>
`;

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

@@ -3,7 +3,8 @@
import classNames from 'classnames';
import React, {memo} from 'react';
import type {HTMLAttributes} from 'react';
import type {HTMLAttributes, SyntheticEvent} from 'react';
import {useIntl} from 'react-intl';
import {Client4} from 'mattermost-redux/client';
@@ -56,33 +57,38 @@ const Avatar = ({
text,
...attrs
}: Props & Attrs) => {
const {formatMessage} = useIntl();
const classes = classNames(`Avatar Avatar-${size}`, attrs.className);
if (text) {
return (
<div
{...attrs}
className={classes + ' Avatar-plain'}
className={classNames(classes, 'Avatar-plain')}
data-content={text}
/>
);
}
function handleOnError(e: SyntheticEvent<HTMLImageElement, Event>) {
const fallbackSrc = (url && isURLForUser(url)) ? replaceURLWithDefaultImageURL(url) : BotDefaultIcon;
if (e.currentTarget.src !== fallbackSrc) {
e.currentTarget.src = fallbackSrc;
}
}
return (
<img
tabIndex={0}
{...attrs}
className={classes}
alt={`${username || 'user'} profile image`}
alt={formatMessage({id: 'avatar.alt', defaultMessage: '{username} profile image'}, {
username: username || 'user',
})}
src={url}
loading='lazy'
onError={(e) => {
const fallbackSrc = (url && isURLForUser(url)) ? replaceURLWithDefaultImageURL(url) : BotDefaultIcon;
if (e.currentTarget.src !== fallbackSrc) {
e.currentTarget.src = fallbackSrc;
}
}}
onError={handleOnError}
/>
);
};

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

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

@@ -16,7 +16,7 @@ jest.mock('mattermost-redux/actions/users', () => {
};
});
import SimpleTooltip from 'components/widgets/simple_tooltip';
import WithTooltip from 'components/with_tooltip';
import {mockStore} from 'tests/test_store';
@@ -149,7 +149,7 @@ describe('components/widgets/users/Avatars', () => {
mountOptions,
);
expect(wrapper.find(SimpleTooltip).find({id: 'names-overflow'}).prop('content')).toBe('first.last4, first.last5');
expect(wrapper.find(WithTooltip).find({id: 'names-overflow'}).prop('title')).toBe('first.last4, first.last5');
});
test('should fetch missing users', () => {
@@ -178,6 +178,6 @@ describe('components/widgets/users/Avatars', () => {
expect(wrapper.find(Avatar).find({url: '/api/v4/users/1/image?_=1620680333191'}).exists()).toBe(true);
expect(wrapper.find(Avatar).find({url: '/api/v4/users/6/image?_=0'}).exists()).toBe(true);
expect(wrapper.find(Avatar).find({url: '/api/v4/users/7/image?_=0'}).exists()).toBe(true);
expect(wrapper.find(SimpleTooltip).find({id: 'names-overflow'}).prop('content')).toBe('first.last2, Someone, Someone');
expect(wrapper.find(WithTooltip).find({id: 'names-overflow'}).prop('title')).toBe('first.last2, Someone, Someone');
});
});

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

@@ -1,11 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useMemo, useEffect, useRef} from 'react';
import React, {memo, useMemo, useEffect} from 'react';
import type {ComponentProps, CSSProperties} from 'react';
import {useIntl} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import styled from 'styled-components';
import tinycolor from 'tinycolor2';
import type {UserProfile} from '@mattermost/types/users';
@@ -14,13 +13,10 @@ import {getMissingProfilesByIds} from 'mattermost-redux/actions/users';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getUser as selectUser, makeDisplayNameGetter} from 'mattermost-redux/selectors/entities/users';
import OverlayTrigger from 'components/overlay_trigger';
import type {BaseOverlayTrigger} from 'components/overlay_trigger';
import ProfilePopover from 'components/profile_popover';
import SimpleTooltip, {useSynchronizedImmediate} from 'components/widgets/simple_tooltip';
import Avatar from 'components/widgets/users/avatar';
import WithTooltip from 'components/with_tooltip';
import {t} from 'utils/i18n';
import {imageURLForUser} from 'utils/utils';
import type {GlobalState} from 'types/store';
@@ -30,16 +26,10 @@ import './avatars.scss';
type Props = {
userIds: Array<UserProfile['id']>;
totalUsers?: number;
breakAt?: number;
size?: ComponentProps<typeof Avatar>['size'];
fetchMissingUsers?: boolean;
disableProfileOverlay?: boolean;
};
interface MMOverlayTrigger extends BaseOverlayTrigger {
hide: () => void;
}
const OTHERS_DISPLAY_LIMIT = 99;
function countMeta<T>(
@@ -61,58 +51,34 @@ const displayNameGetter = makeDisplayNameGetter();
function UserAvatar({
userId,
overlayProps,
disableProfileOverlay,
...props
}: {
userId: UserProfile['id'];
overlayProps: Partial<ComponentProps<typeof SimpleTooltip>>;
disableProfileOverlay: boolean;
} & ComponentProps<typeof Avatar>) {
const user = useSelector((state: GlobalState) => selectUser(state, userId)) as UserProfile | undefined;
const name = useSelector((state: GlobalState) => displayNameGetter(state, true)(user));
const profilePictureURL = userId ? imageURLForUser(userId) : '';
const overlay = useRef<MMOverlayTrigger>(null);
const hideProfilePopover = () => {
overlay.current?.hide();
};
return (
<OverlayTrigger
trigger='click'
disabled={disableProfileOverlay}
placement='right'
rootClose={true}
ref={overlay}
overlay={
<ProfilePopover
className='user-profile-popover'
userId={userId}
src={profilePictureURL}
hide={hideProfilePopover}
/>
}
<ProfilePopover<HTMLButtonElement>
triggerComponentAs='button'
triggerComponentClass='style--none rounded-button'
userId={userId}
src={profilePictureURL}
>
<SimpleTooltip
id={`name-${userId}`}
content={name}
{...overlayProps}
<WithTooltip
id={`tooltip-name-${userId}`}
title={name}
placement='top'
>
<RoundButton
className={'style--none'}
onClick={(e) => e.stopPropagation()}
>
<Avatar
url={imageURLForUser(userId, user?.last_picture_update)}
tabIndex={-1}
{...props}
/>
</RoundButton>
</SimpleTooltip>
</OverlayTrigger>
<Avatar
url={imageURLForUser(userId, user?.last_picture_update)}
tabIndex={-1}
{...props}
/>
</WithTooltip>
</ProfilePopover>
);
}
@@ -121,11 +87,9 @@ function Avatars({
userIds,
totalUsers,
fetchMissingUsers = true,
disableProfileOverlay = false,
}: Props) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const [overlayProps, setImmediate] = useSynchronizedImmediate();
const [displayUserIds, overflowUserIds, {overflowUnnamedCount, nonDisplayCount}] = countMeta(userIds, totalUsers);
const overflowNames = useSelector((state: GlobalState) => {
return overflowUserIds.map((userId) => displayNameGetter(state, true)(selectUser(state, userId))).join(', ');
@@ -142,10 +106,33 @@ function Avatars({
}
}, [fetchMissingUsers, userIds]);
let overflowUsersTooltip = '';
if (nonDisplayCount) {
if (overflowUserIds.length) {
overflowUsersTooltip = formatMessage(
{
id: 'avatars.overflowUsers',
defaultMessage: '{overflowUnnamedCount, plural, =0 {{names}} =1 {{names} and one other} other {{names} and # others}}',
},
{
overflowUnnamedCount,
names: overflowNames,
},
);
} else {
overflowUsersTooltip = formatMessage(
{
id: 'avatars.overflowUnnamedOnly',
defaultMessage: '{overflowUnnamedCount, plural, =1 {one other} other {# others}}',
},
{overflowUnnamedCount},
);
}
}
return (
<div
className={`Avatars Avatars___${size}`}
onMouseLeave={() => setImmediate(false)}
>
{displayUserIds.map((id) => (
<UserAvatar
@@ -153,30 +140,13 @@ function Avatars({
key={id}
userId={id}
size={size}
overlayProps={overlayProps}
disableProfileOverlay={disableProfileOverlay}
/>
))}
{Boolean(nonDisplayCount) && (
<SimpleTooltip
<WithTooltip
id={'names-overflow'}
{...overlayProps}
content={overflowUserIds.length ? formatMessage(
{
id: t('avatars.overflowUsers'),
defaultMessage: '{overflowUnnamedCount, plural, =0 {{names}} =1 {{names} and one other} other {{names} and # others}}',
},
{
overflowUnnamedCount,
names: overflowNames,
},
) : formatMessage(
{
id: t('avatars.overflowUnnamedOnly'),
defaultMessage: '{overflowUnnamedCount, plural, =1 {one other} other {# others}}',
},
{overflowUnnamedCount},
)}
placement='top'
title={overflowUsersTooltip}
>
<Avatar
style={avatarStyle}
@@ -184,14 +154,10 @@ function Avatars({
tabIndex={0}
text={nonDisplayCount > OTHERS_DISPLAY_LIMIT ? `${OTHERS_DISPLAY_LIMIT}+` : `+${nonDisplayCount}`}
/>
</SimpleTooltip>
</WithTooltip>
)}
</div>
);
}
const RoundButton = styled.button`
border-radius: 50%;
`;
export default memo(Avatars);

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

@@ -2944,6 +2944,7 @@
"authorize.app": "The app **{appName}** would like the ability to access and modify your basic information.",
"authorize.deny": "Deny",
"authorize.title": "Authorize **{appName}** to Connect to Your **Mattermost** User Account",
"avatar.alt": "{username} profile image",
"avatars.overflowUnnamedOnly": "{overflowUnnamedCount, plural, =1 {one other} other {# others}}",
"avatars.overflowUsers": "{overflowUnnamedCount, plural, =0 {{names}} =1 {{names} and one other} other {{names} and # others}}",
"backstage_list.search": "Search",
@@ -4590,7 +4591,6 @@
"pricing_modal.title": "Select a plan",
"pricing_modal.wantToTry": "Want to try? ",
"pricing_modal.wantToUpgrade": "Want to upgrade? ",
"profile_popover.profileLabel": "Profile for {name}",
"promote_to_user_modal.desc": "This action promotes the guest {username} to a member. It will allow the user to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to promote guest {username} to member?",
"promote_to_user_modal.promote": "Promote",
"promote_to_user_modal.title": "Promote guest {username} to member",
@@ -5216,7 +5216,7 @@
"url_input.buttonLabel.done": "Done",
"url_input.buttonLabel.edit": "Edit",
"url_input.label.url": "URL: ",
"user_group_popover.close": "Close",
"user_group_popover.close": "Close user group popover",
"user_group_popover.memberCount": "{member_count} {member_count, plural, one {Member} other {Members}}",
"user_group_popover.openGroupModal": "View full group info",
"user_group_popover.searchGroupMembers": "Search members",
@@ -5260,12 +5260,14 @@
"user_profile.account.localTimeWithTimezone": "Local Time ({timezone})",
"user_profile.account.post_was_created": "This post was created by an integration from @{username}",
"user_profile.add_user_to_channel": "Add to a Channel",
"user_profile.add_user_to_channel.icon": "Add User to Channel Icon",
"user_profile.call.ongoing": "Call with {user} is ongoing",
"user_profile.close": "Close user profile popover",
"user_profile.custom_status": "Status",
"user_profile.custom_status.set_status": "Set a status",
"user_profile.roleTitle.channel_admin": "Channel Admin",
"user_profile.roleTitle.system_admin": "System Admin",
"user_profile.roleTitle.team_admin": "Team Admin",
"user_profile.send.dm": "Message",
"user_profile.send.dm.icon": "Send Message Icon",
"user_profile.send.dm.yourself": "Send yourself a message",
"user.settings.advance.confirmDeactivateAccountTitle": "Confirm Deactivation",
"user.settings.advance.confirmDeactivateDesc": "Are you sure you want to deactivate your account? This can only be reversed by your System Administrator.",

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

@@ -21,6 +21,10 @@
}
}
.rounded-button {
border-radius: 50%;
}
button {
.unread-badge {
display: inline-block;

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

@@ -19,52 +19,6 @@
.app__body & {
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
.user-profile-popover {
border: 1px solid rgba(var(--center-channel-color-rgb), 0.2);
background: transparent !important;
box-shadow: none !important;
.popover-title {
position: relative;
border-color: var(--center-channel-bg);
background: none;
&::before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
content: '';
}
span {
position: relative;
z-index: 1;
}
}
.popover-content {
border-radius: 0 0 3px 3px;
border-top: none;
background: v(center-channel-bg);
box-shadow: rgba(var(--center-channel-color-rgb), 0.08) 0 17px 50px 0, rgba(var(--center-channel-color-rgb), 0.08) 0 12px 15px 0;
.shared-user-icon {
width: 16px;
height: 16px;
margin-left: 8px;
color: rgba(61, 60, 64, 0.75);
font-size: 16px;
line-height: 20px;
&::before {
margin: 0;
}
}
}
}
}
&.channel-header__popover {
@@ -257,66 +211,6 @@
}
}
.user-popover-image {
position: relative;
display: block;
width: 128px;
margin: 0 auto 8px;
#userAvatar {
width: 120px;
min-width: 120px;
height: 120px;
}
.user-popover-status {
position: absolute;
top: auto;
right: 8px;
bottom: 0;
display: flex;
width: 24px;
height: 24px;
padding: 2px;
border-radius: 50px;
background: rgba(var(--center-channel-bg-rgb), 1);
svg {
width: 100%;
min-height: 100%;
}
}
}
.user-profile-popover .Avatar {
display: block;
margin: 5px auto;
}
.user-popover__email {
display: block;
overflow: hidden;
max-width: 300px;
text-overflow: ellipsis;
}
.user-popover__close {
display: flex;
padding: 6px;
border: none;
margin-left: auto;
background: none;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-weight: 400;
line-height: 18px;
&:hover {
border-radius: 4px;
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.8);
}
}
.hidden-label + .search-autocomplete__divider {
&::before {
display: none;