From 4b6216f5a4e3c7e74c7359591d88c0bc33ed50a9 Mon Sep 17 00:00:00 2001
From: M-ZubairAhmed
Date: Wed, 15 Jan 2025 00:55:38 +0530
Subject: [PATCH] [MM-55274] User account menu doesn't follow standard
accessible keyboard behavior (#29173)
---
.../accessibility/accessibility_image_spec.js | 6 +-
.../account_settings/profile/email_spec.ts | 5 +-
.../account_settings/profile/username_spec.ts | 4 +-
.../join_archived_channel_spec.ts | 2 +-
.../sidebar_category_menu_spec.ts | 6 +
.../custom_status/custom_status_2_spec.ts | 26 +-
.../custom_status/custom_status_3_spec.ts | 28 +-
.../custom_status/custom_status_4_spec.ts | 2 +-
.../custom_status/custom_status_5_spec.ts | 2 +-
.../custom_status_expiry_1_spec.ts | 55 +-
.../custom_status_expiry_2_spec.ts | 78 +-
.../custom_status_expiry_3_spec.ts | 6 +-
.../custom_status_expiry_4_spec.ts | 6 +-
.../user_status_commands_spec.js | 10 +-
.../builtin_commands/user_status_spec.js | 15 +-
.../channels/menus/main_menu_spec.js | 7 +-
.../channels/menus/status_dropdown_spec.js | 44 +-
...th_same_firstname_channel_mentions_spec.js | 2 +-
.../profile_settings/profile_settings_spec.js | 4 +-
.../theme/custom_theme_sidebar_styles_spec.js | 8 +-
.../login_logout_smoke_spec.js | 2 +-
.../login_open_server_spec.js | 4 +-
.../channels/status/status_updates_spec.ts | 8 +-
.../tests/support/common_login_commands.js | 2 +-
.../cypress/tests/support/ui/global_header.js | 17 +-
e2e-tests/cypress/tests/support/ui/menu.js | 12 +-
.../src/actions/views/status_dropdown.test.ts | 28 -
.../src/actions/views/status_dropdown.ts | 11 -
.../custom_status/custom_status_emoji.tsx | 3 +-
.../components/custom_status/expiry_time.tsx | 3 +-
.../src/components/emoji/render_emoji.tsx | 3 +-
.../right_controls/right_controls.tsx | 10 +-
webapp/channels/src/components/menu/index.ts | 3 +
webapp/channels/src/components/menu/menu.scss | 28 +
webapp/channels/src/components/menu/menu.tsx | 50 +-
.../src/components/menu/menu_item.tsx | 26 +-
.../src/components/menu/menu_styled.tsx | 32 -
.../src/components/menu/sub_menu.scss | 11 -
.../channels/src/components/menu/sub_menu.tsx | 16 +-
.../complete_your_profile_tour_tip.tsx | 3 +-
.../onboarding_tasks_manager.tsx | 4 +-
.../post_header_custom_status.tsx | 8 +-
.../status_dropdown.test.tsx.snap | 2552 -----------------
.../src/components/status_dropdown/index.ts | 73 -
.../status_dropdown/status_dropdown.scss | 358 ---
.../status_dropdown/status_dropdown.test.tsx | 194 --
.../status_dropdown/status_dropdown.tsx | 701 -----
.../user_account_name_menuitem.test.tsx.snap | 32 +
.../src/components/user_account_menu/index.ts | 61 +
.../user_account_away_menuitem.tsx | 80 +
.../user_account_custom_status_menuitem.tsx | 140 +
.../user_account_dnd_menuitem.tsx | 305 ++
.../user_account_logout_menuitem.tsx | 35 +
.../user_account_menu/user_account_menu.scss | 172 ++
.../user_account_menu/user_account_menu.tsx | 133 +
.../user_account_menuButton.tsx | 139 +
.../user_account_name_menuitem.test.tsx | 92 +
.../user_account_name_menuitem.tsx | 78 +
.../user_account_offline_menuitem.tsx | 80 +
.../user_account_online_menuitem.tsx | 80 +
...er_account_out_of_office_menuitem.test.tsx | 57 +
.../user_account_out_of_office_menuitem.tsx | 76 +
.../user_account_profile_menuitem.tsx | 80 +
...ser_account_set_custom_status_menuitem.tsx | 35 +
.../view_user_group_list_item.tsx | 1 -
webapp/channels/src/i18n/en.json | 57 +-
webapp/channels/src/reducers/views/index.ts | 2 -
.../src/reducers/views/status_dropdown.ts | 26 -
.../channels/src/sass/utils/_variables.scss | 3 +
.../selectors/views/status_dropdown.test.ts | 19 -
.../src/selectors/views/status_dropdown.ts | 8 -
webapp/channels/src/types/store/views.ts | 4 -
webapp/channels/src/utils/constants.tsx | 1 -
webapp/channels/src/utils/keyboard.ts | 17 +
.../src/pulsating_dot/pulsating_dot.scss | 34 +
75 files changed, 2020 insertions(+), 4305 deletions(-)
delete mode 100644 webapp/channels/src/actions/views/status_dropdown.test.ts
delete mode 100644 webapp/channels/src/actions/views/status_dropdown.ts
create mode 100644 webapp/channels/src/components/menu/menu.scss
delete mode 100644 webapp/channels/src/components/menu/menu_styled.tsx
delete mode 100644 webapp/channels/src/components/menu/sub_menu.scss
delete mode 100644 webapp/channels/src/components/status_dropdown/__snapshots__/status_dropdown.test.tsx.snap
delete mode 100644 webapp/channels/src/components/status_dropdown/index.ts
delete mode 100644 webapp/channels/src/components/status_dropdown/status_dropdown.scss
delete mode 100644 webapp/channels/src/components/status_dropdown/status_dropdown.test.tsx
delete mode 100644 webapp/channels/src/components/status_dropdown/status_dropdown.tsx
create mode 100644 webapp/channels/src/components/user_account_menu/__snapshots__/user_account_name_menuitem.test.tsx.snap
create mode 100644 webapp/channels/src/components/user_account_menu/index.ts
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_away_menuitem.tsx
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_custom_status_menuitem.tsx
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_dnd_menuitem.tsx
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_logout_menuitem.tsx
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_menu.scss
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_menu.tsx
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_menuButton.tsx
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_name_menuitem.test.tsx
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_name_menuitem.tsx
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_offline_menuitem.tsx
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_online_menuitem.tsx
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_out_of_office_menuitem.test.tsx
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_out_of_office_menuitem.tsx
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_profile_menuitem.tsx
create mode 100644 webapp/channels/src/components/user_account_menu/user_account_set_custom_status_menuitem.tsx
delete mode 100644 webapp/channels/src/reducers/views/status_dropdown.ts
delete mode 100644 webapp/channels/src/selectors/views/status_dropdown.test.ts
delete mode 100644 webapp/channels/src/selectors/views/status_dropdown.ts
diff --git a/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_image_spec.js b/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_image_spec.js
index c5963ef7e1..5703f9c38a 100644
--- a/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_image_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_image_spec.js
@@ -26,9 +26,9 @@ describe('Verify Accessibility Support in Different Images', () => {
it('MM-T1508 Accessibility support in different images', () => {
// * Verify image alt in profile image
- cy.uiGetProfileHeader().
- find('.Avatar').
- should('have.attr', 'alt', 'user profile image');
+ cy.get('#userAccountMenuButton').within(() => {
+ cy.findByAltText('user profile image').should('be.visible');
+ });
// # Upload an image in the post
cy.get('#fileUploadInput').attachFile('small-image.png');
diff --git a/e2e-tests/cypress/tests/integration/channels/account_settings/profile/email_spec.ts b/e2e-tests/cypress/tests/integration/channels/account_settings/profile/email_spec.ts
index 44bac52cc8..8a264d2579 100644
--- a/e2e-tests/cypress/tests/integration/channels/account_settings/profile/email_spec.ts
+++ b/e2e-tests/cypress/tests/integration/channels/account_settings/profile/email_spec.ts
@@ -163,9 +163,6 @@ describe('Profile > Profile Settings > Email', () => {
// # Click on the link
cy.visit(permalink);
- // * Verify announcement bar
- cy.get('.announcement-bar').should('be.visible').should('contain.text', 'Email verified');
-
// # Wait for one second for the mail to be sent out.
cy.wait(TIMEOUTS.FIVE_SEC);
@@ -199,7 +196,7 @@ describe('Profile > Profile Settings > Email', () => {
// # Close modal then logout
cy.get('body').type('{esc}');
- cy.uiOpenUserMenu('Log Out');
+ cy.uiLogout();
// # Wait for one second for the mail to be sent out.
cy.wait(TIMEOUTS.ONE_SEC);
diff --git a/e2e-tests/cypress/tests/integration/channels/account_settings/profile/username_spec.ts b/e2e-tests/cypress/tests/integration/channels/account_settings/profile/username_spec.ts
index 7fe8a4d767..6e2c5a7c6b 100644
--- a/e2e-tests/cypress/tests/integration/channels/account_settings/profile/username_spec.ts
+++ b/e2e-tests/cypress/tests/integration/channels/account_settings/profile/username_spec.ts
@@ -116,8 +116,8 @@ describe('Settings > Sidebar > General > Edit', () => {
// # Step 3
// * Verify that we've logged in as the temp user
cy.visit(offTopicUrl);
- cy.uiOpenUserMenu().findByText(`@${newTempUserName}`);
- cy.uiGetSetStatusButton().click();
+ cy.uiOpenUserMenu().findByText(`@${newTempUserName}`).should('exist');
+ cy.get('body').type('{esc}');
// # Step 4
const text = `${newTempUserName} test message!`;
diff --git a/e2e-tests/cypress/tests/integration/channels/archived_channel/join_archived_channel_spec.ts b/e2e-tests/cypress/tests/integration/channels/archived_channel/join_archived_channel_spec.ts
index 231d8579ff..e4dd367d1a 100644
--- a/e2e-tests/cypress/tests/integration/channels/archived_channel/join_archived_channel_spec.ts
+++ b/e2e-tests/cypress/tests/integration/channels/archived_channel/join_archived_channel_spec.ts
@@ -124,5 +124,5 @@ function verifyUsername(username) {
cy.uiOpenUserMenu().findByText(`@${username}`);
// # Close the user menu
- cy.uiGetSetStatusButton().click();
+ cy.get('body').type('{esc}');
}
diff --git a/e2e-tests/cypress/tests/integration/channels/channel_sidebar/sidebar_category_menu_spec.ts b/e2e-tests/cypress/tests/integration/channels/channel_sidebar/sidebar_category_menu_spec.ts
index 16f3e9cf1a..5413cae2de 100644
--- a/e2e-tests/cypress/tests/integration/channels/channel_sidebar/sidebar_category_menu_spec.ts
+++ b/e2e-tests/cypress/tests/integration/channels/channel_sidebar/sidebar_category_menu_spec.ts
@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import * as TIMEOUTS from '../../../fixtures/timeouts';
+
import {clickCategoryMenuItem} from './helpers';
// ***************************************************************
@@ -22,6 +24,8 @@ describe('Sidebar category menu', () => {
it('MM-T3171_1 Verify that the 3-dot menu on the Channels Category contains an option to Create New Category', () => {
clickCategoryMenuItem('CHANNELS', 'Create New Category');
+ cy.wait(TIMEOUTS.ONE_SEC);
+
cy.get('body').type('{esc}', {force: true});
});
@@ -41,6 +45,8 @@ describe('Sidebar category menu', () => {
// # Verify that Create New Category exists on Favorites category and click on it
clickCategoryMenuItem('FAVORITES', 'Create New Category');
+ cy.wait(TIMEOUTS.ONE_SEC);
+
cy.get('body').type('{esc}', {force: true});
});
});
diff --git a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_2_spec.ts b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_2_spec.ts
index 8fc58cc757..16f453e945 100644
--- a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_2_spec.ts
+++ b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_2_spec.ts
@@ -28,15 +28,23 @@ describe('Custom Status - Setting a Custom Status', () => {
it('MM-T3836_1 should open status dropdown', () => {
// # Click on the sidebar header to open status dropdown
- cy.get('.MenuWrapper .status-wrapper').click();
+ cy.uiGetSetStatusButton().click();
// * Check if the status dropdown opens
- cy.get('#statusDropdownMenu').should('exist');
+ cy.uiGetStatusMenuContainer();
+
+ // # Close the status dropdown
+ cy.get('body').type('{esc}');
});
it('MM-T3836_2 Custom status modal opens with 5 default statuses listed', () => {
+ // # Click on the sidebar header to open status dropdown
+ cy.uiGetSetStatusButton().click();
+
+ // * Check if the status dropdown opens
+ cy.uiGetStatusMenuContainer().findByText('Set custom status').click();
+
// # Open custom status modal
- cy.get('#statusDropdownMenu li#status-menu-custom-status').click();
cy.get('#custom_status_modal').should('exist');
// * Check if all the default suggestions exist
@@ -111,13 +119,15 @@ describe('Custom Status - Setting a Custom Status', () => {
trigger('mouseover');
// * Custom status tooltip should be visible
- cy.get('#custom-status-tooltip').should('exist');
+ cy.get('#custom-status-tooltip').should('exist').and('be.visible');
- // * Tooltip should contain the correct custom status emoji
- cy.get('#custom-status-tooltip .custom-status span.emoticon').invoke('attr', 'data-emoticon').should('contain', customStatus.emoji);
+ cy.get('#custom-status-tooltip').within(() => {
+ // * Tooltip should contain the correct custom status emoji
+ cy.get(`span[data-emoticon="${customStatus.emoji}"]`).should('exist');
- // * Tooltip should contain the correct custom status text
- cy.get('#custom-status-tooltip .custom-status span.custom-status-text').should('have.text', customStatus.text);
+ // * Tooltip should contain the correct custom status text
+ cy.findByText(customStatus.text).should('exist');
+ });
});
it('MM-T3836_8 should open custom status modal when emoji in LHS header is clicked', () => {
diff --git a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_3_spec.ts b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_3_spec.ts
index b9a69f6671..ad1591b439 100644
--- a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_3_spec.ts
+++ b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_3_spec.ts
@@ -27,7 +27,7 @@ describe('Custom Status - Setting Your Own Custom Status', () => {
it('MM-T3846_1 should change the emoji to speech balloon when typed in the input', () => {
// # Open the custom status modal
- cy.uiOpenUserMenu('Set a custom status');
+ cy.uiOpenUserMenu('Set custom status');
// * Default emoji is currently visible in the custom status input
cy.get('#custom_status_modal .StatusModal__emoji-button span').should('have.class', 'icon--emoji');
@@ -72,20 +72,26 @@ describe('Custom Status - Setting Your Own Custom Status', () => {
cy.uiGetProfileHeader().
find('.emoticon').
should('have.attr', 'data-emoticon', customStatus.emoji);
+
+ cy.get('body').type('{esc}');
});
it('MM-T3846_5 should show custom status with emoji in the status dropdown', () => {
// # Open user menu
- cy.uiOpenUserMenu();
+ cy.uiOpenUserMenu().within(() => {
+ // * Correct custom status text and emoji should be displayed in the status dropdown
+ cy.findByText(customStatus.text).should('exist');
+ cy.get(`span[data-emoticon="${customStatus.emoji}"]`).should('exist');
+ });
- // * Correct custom status text and emoji should be displayed in the status dropdown
- cy.get('.status-dropdown-menu .custom_status__container').should('have.text', customStatus.text);
- cy.get('.status-dropdown-menu .custom_status__row span.emoticon').invoke('attr', 'data-emoticon').should('contain', customStatus.emoji);
+ cy.get('body').type('{esc}');
});
- it('MM-T3846_6 should show previosly set status in the first position in Recents list', () => {
+ it('MM-T3846_6 should show previously set status in the first position in Recents list', () => {
// # Click on the "Set a Custom Status" option in the status dropdown
- cy.get('.status-dropdown-menu li#status-menu-custom-status').click();
+ cy.uiOpenUserMenu().within(() => {
+ cy.findByText(customStatus.text).should('exist').click({force: true});
+ });
// * Custom status modal should open
cy.get('#custom_status_modal').should('exist');
@@ -118,9 +124,9 @@ describe('Custom Status - Setting Your Own Custom Status', () => {
cy.findByText('Clear Status').click();
// # Open user menu
- cy.uiOpenUserMenu();
-
- // * Custom status text should not be displayed in the status dropdown
- cy.get('.status-dropdown-menu .custom_status__row').should('not.have.text', customStatus.text);
+ cy.uiOpenUserMenu().within(() => {
+ // * Custom status text should not be displayed in the status dropdown
+ cy.findByText(customStatus.text).should('not.exist');
+ });
});
});
diff --git a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_4_spec.ts b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_4_spec.ts
index 8daaf69319..8caddd651e 100644
--- a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_4_spec.ts
+++ b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_4_spec.ts
@@ -32,7 +32,7 @@ describe('Custom Status - Recent Statuses', () => {
it('MM-T3847_1 set a status', () => {
// # Open the custom status modal
- cy.uiOpenUserMenu('Set a custom status');
+ cy.uiOpenUserMenu('Set custom status');
// # Type the custom status text in the custom status modal input
cy.get('#custom_status_modal .StatusModal__input input').typeWithForce(customStatus.text);
diff --git a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_5_spec.ts b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_5_spec.ts
index 1cc20c8d76..764e810180 100644
--- a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_5_spec.ts
+++ b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_5_spec.ts
@@ -27,7 +27,7 @@ describe('Custom Status - Verifying Where Custom Status Appears', () => {
});
it('MM-T3850_1 set a status', () => {
- cy.uiOpenUserMenu('Set a Custom Status');
+ cy.uiOpenUserMenu('Set custom status');
// # Type the custom status text in the custom status modal input
cy.findByPlaceholderText('Set a status').type(customStatus.text, {force: true});
diff --git a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_1_spec.ts b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_1_spec.ts
index 13ae7afbb4..d09034c61b 100644
--- a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_1_spec.ts
+++ b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_1_spec.ts
@@ -25,6 +25,7 @@ describe('MM-T4063 Custom status expiry', () => {
const defaultCustomStatuses = ['In a meeting', 'Out for lunch', 'Out sick', 'Working from home', 'On a vacation'];
const customStatus = {
emoji: 'hamburger',
+ emojiAriaLabel: ':hamburger:',
text: 'Out for lunch',
duration: '30 minutes',
};
@@ -36,19 +37,25 @@ describe('MM-T4063 Custom status expiry', () => {
const expiryTimeFormat = 'h:mm A';
it('MM-T4063_1 should open status dropdown', () => {
// # Click on the sidebar header to open status dropdown
- cy.get('.MenuWrapper .status-wrapper').click();
+ cy.uiGetSetStatusButton().click();
// * Check if the status dropdown opens
- cy.get('#statusDropdownMenu').should('exist');
+ cy.uiGetStatusMenu();
+
+ // # Close the status dropdown
+ cy.get('body').click();
});
it('MM-T4063_2 Custom status modal opens with 5 default statuses listed', () => {
// # Open custom status modal
- cy.get('#statusDropdownMenu li#status-menu-custom-status').click();
- cy.get('#custom_status_modal').should('exist');
+ cy.uiOpenUserMenu('Set custom status');
- // * Check if all the default suggestions exist
- defaultCustomStatuses.map((statusText) => cy.get('#custom_status_modal .statusSuggestion__content').contains('span', statusText));
+ cy.findByRole('dialog', {name: 'Set a status'}).should('exist').within(() => {
+ // * Check if all the default suggestions exist
+ defaultCustomStatuses.forEach((statusText) => {
+ cy.findByText(statusText).should('exist');
+ });
+ });
});
it('MM-T4063_3 Correct custom status is selected with the correct emoji and correct duration', () => {
@@ -94,17 +101,17 @@ describe('MM-T4063 Custom status expiry', () => {
it('MM-T4063_5 should show the set custom status with expiry when status dropdown is opened', () => {
// # Click on the sidebar header to open status dropdown
- cy.get('.MenuWrapper .status-wrapper').click();
+ cy.uiGetSetStatusButton().click();
// * Check if the status dropdown opens
- cy.get('#statusDropdownMenu').should('exist');
+ cy.uiGetStatusMenu().within(() => {
+ // * Correct custom status text and emoji should be displayed in the status dropdown
+ cy.findByText(customStatus.text).should('exist');
+ cy.findByLabelText(customStatus.emojiAriaLabel).should('exist');
- // * Correct custom status text and emoji should be displayed in the status dropdown
- cy.get('.status-dropdown-menu .custom_status__container').should('have.text', customStatus.text);
- cy.get('.status-dropdown-menu .custom_status__row span.emoticon').invoke('attr', 'data-emoticon').should('contain', customStatus.emoji);
-
- // * Correct clear time should be displayed in the status dropdown
- cy.get('.status-dropdown-menu .custom_status__expiry time').invoke('text').should('match', expiresAtRegexp);
+ // * Correct clear time should be displayed in the status dropdown
+ cy.findByText(expiresAtRegexp).should('exist');
+ });
});
it('MM-T4063_6 custom status should be cleared after duration of set custom status', () => {
@@ -118,8 +125,7 @@ describe('MM-T4063 Custom status expiry', () => {
it('MM-T4063_7 current custom status should display expiry time in custom status modal', () => {
// # Open custom status modal
- cy.get('#statusDropdownMenu li#status-menu-custom-status').click();
- cy.get('#custom_status_modal').should('exist');
+ cy.get('.userAccountMenu_customStatusMenuItem').should('be.visible').click();
// * Should show expiry time of status when current status is selected
cy.get('#custom_status_modal .statusSuggestion__content').contains('span', customStatus.text).click();
@@ -132,15 +138,12 @@ describe('MM-T4063 Custom status expiry', () => {
it('MM-T4063_8 previous custom status duration should be reset if custom status is expired', () => {
// # Forwarding the time by the duration of custom status
cy.clock(Date.now());
- cy.tick(waitingTime * 60 * 1000);
-
- // # Open custom status modal
- cy.get('.status-dropdown-menu').click();
- cy.get('#statusDropdownMenu li#status-menu-custom-status').click();
- cy.get('#custom_status_modal').should('exist');
-
- // * Should show duration of previous status when previous status is selected
- cy.get('#custom_status_modal .statusSuggestion__content').contains('span', customStatus.text).click();
- cy.get('#custom_status_modal .expiry-value').should('have.text', customStatus.duration);
+ cy.tick(waitingTime * 60 * 1000).then(() => {
+ // # Open custom status modal
+ cy.uiOpenUserMenu().within(() => {
+ // * Verify that there is no custom status in the dropdown
+ cy.findByText('Set custom status').should('exist').and('be.visible');
+ });
+ });
});
});
diff --git a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_2_spec.ts b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_2_spec.ts
index a15928b29c..61474e4ae8 100644
--- a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_2_spec.ts
+++ b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_2_spec.ts
@@ -21,86 +21,30 @@ describe('MM-T4064 Status expiry visibility', () => {
cy.visit(channelUrl);
});
});
-
- const defaultCustomStatuses = ['In a meeting', 'Out for lunch', 'Out sick', 'Working from home', 'On a vacation'];
- const customStatus = {
- emoji: 'calendar',
- text: 'In a meeting',
- duration: '1 hour',
- };
-
const waitingTime = 60; //minutes
let expiresAt = dayjs();
const expiryTimeFormat = 'h:mm A';
- it('MM-T4064_1 should open status dropdown', () => {
- // # Click on the sidebar header to open status dropdown
- cy.get('.MenuWrapper .status-wrapper').click();
- // * Check if the status dropdown opens
- cy.get('#statusDropdownMenu').should('exist');
- });
+ it('MM-T4064_6 should show expiry time in the tooltip of custom status emoji in the post header', () => {
+ // # Open the user account menu
+ cy.uiOpenUserMenu('Set custom status');
- it('MM-T4064_2 Custom status modal opens with 5 default statuses listed', () => {
- // # Open custom status modal
- cy.get('#statusDropdownMenu li#status-menu-custom-status').click();
- cy.get('#custom_status_modal').should('exist');
+ // * Verify that the custom status modal opens
+ cy.findByRole('dialog', {name: 'Set a status'}).should('exist').within(() => {
+ // # Select a custom status from the suggestions
+ cy.get('.statusSuggestion__row').first().click();
- // * Check if all the default suggestions exist
- defaultCustomStatuses.map((statusText) => cy.get('#custom_status_modal .statusSuggestion__content').contains('span', statusText));
- });
-
- it('MM-T4064_3 Correct custom status is selected with the correct emoji and correct duration', () => {
- // * Default emoji is currently visible in the custom status input
- cy.get('#custom_status_modal .StatusModal__emoji-button span').should('have.class', 'icon--emoji');
-
- // * Input should be empty
- cy.get('#custom_status_modal input.form-control').should('have.value', '');
-
- // # Select a custom status from the suggestions
- cy.get('#custom_status_modal .statusSuggestion__content').contains('span', customStatus.text).click();
-
- // * Emoji in the custom status input should be changed
- cy.get('#custom_status_modal .StatusModal__emoji-button span').invoke('attr', 'data-emoticon').should('contain', customStatus.emoji);
-
- // * Selected custom status text should be in the input
- cy.get('#custom_status_modal input.form-control').should('have.value', customStatus.text);
-
- // * Selected custom status duration should be displayed in the Clear after section
- cy.get('#custom_status_modal .expiry-wrapper .expiry-value').should('have.text', customStatus.duration);
- });
-
- it('MM-T4064_4 should set custom status when click on Set Status', () => {
- // # Click on the Set Status button
- cy.get('#custom_status_modal .GenericModal__button.confirm').click();
+ // # Click on the Set Status button
+ cy.findByText('Set Status').click();
+ });
// * Modal should be closed
cy.get('#custom_status_modal').should('not.exist');
// # Setting the time at which the custom status should be expired
+ // # Note that we need to be flexible around accepted values, as this calculation and the server-side one may differ slightly
expiresAt = dayjs().add(waitingTime, 'minute');
- // * Status should be set and the emoji should be visible in the sidebar header
- cy.uiGetProfileHeader().
- find('.emoticon').
- should('have.attr', 'data-emoticon', customStatus.emoji);
- });
-
- it('MM-T4064_5 should show the set custom status with expiry when status dropdown is opened', () => {
- // # Click on the sidebar header to open status dropdown
- cy.get('.MenuWrapper .status-wrapper').click();
-
- // * Check if the status dropdown opens
- cy.get('#statusDropdownMenu').should('exist');
-
- // * Correct custom status text and emoji should be displayed in the status dropdown
- cy.get('.status-dropdown-menu .custom_status__container').should('have.text', customStatus.text);
- cy.get('.status-dropdown-menu .custom_status__row span.emoticon').invoke('attr', 'data-emoticon').should('contain', customStatus.emoji);
-
- // * Correct clear time should be displayed in the status dropdown
- cy.get('.status-dropdown-menu .custom_status__expiry time').should('have.text', expiresAt.format(expiryTimeFormat));
- });
-
- it('MM-T4064_6 should show expiry time in the tooltip of custom status emoji in the post header', () => {
// # Post a message in the channel
cy.postMessage('Hello World!');
diff --git a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_3_spec.ts b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_3_spec.ts
index 67eaebd3ae..ede43e3f20 100644
--- a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_3_spec.ts
+++ b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_3_spec.ts
@@ -39,15 +39,15 @@ describe('MM-T4065 Setting manual status clear time less than 7 days away', () =
it('MM-T4065_1 should open status dropdown', () => {
// # Click on the sidebar header to open status dropdown
- cy.get('.MenuWrapper .status-wrapper').click();
+ cy.uiGetSetStatusButton().click();
// * Check if the status dropdown opens
- cy.get('#statusDropdownMenu').should('exist');
+ cy.get('#userAccountMenu').should('exist');
});
it('MM-T4065_2 Custom status modal opens with 5 default statuses listed', () => {
// # Open custom status modal
- cy.get('#statusDropdownMenu li#status-menu-custom-status').click();
+ cy.get('.userAccountMenu_setCustomStatusMenuItem').click();
cy.get('#custom_status_modal').should('exist');
// * Check if all the default suggestions exist
diff --git a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_4_spec.ts b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_4_spec.ts
index d75de18c17..7307dc3709 100644
--- a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_4_spec.ts
+++ b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_4_spec.ts
@@ -38,15 +38,15 @@ describe('MM-T4066 Setting manual status clear time more than 7 days away', () =
const months = dateToBeSelected.get('month') - today.get('month');
it('MM-T4066_1 should open status dropdown', () => {
// # Click on the sidebar header to open status dropdown
- cy.get('.MenuWrapper .status-wrapper').click();
+ cy.uiGetSetStatusButton().click();
// * Check if the status dropdown opens
- cy.get('#statusDropdownMenu').should('exist');
+ cy.get('#userAccountMenu').should('exist');
});
it('MM-T4066_2 Custom status modal opens with 5 default statuses listed', () => {
// # Open custom status modal
- cy.get('#statusDropdownMenu li#status-menu-custom-status').click();
+ cy.get('.userAccountMenu_setCustomStatusMenuItem').click();
cy.get('#custom_status_modal').should('exist');
// * Check if all the default suggestions exist
diff --git a/e2e-tests/cypress/tests/integration/channels/integrations/builtin_commands/user_status_commands_spec.js b/e2e-tests/cypress/tests/integration/channels/integrations/builtin_commands/user_status_commands_spec.js
index d44747c6d0..840be19f45 100644
--- a/e2e-tests/cypress/tests/integration/channels/integrations/builtin_commands/user_status_commands_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/integrations/builtin_commands/user_status_commands_spec.js
@@ -12,10 +12,10 @@
describe('Integrations', () => {
const testCases = [
- {command: '/away', className: 'icon-clock', message: 'You are now away'},
- {command: '/dnd', className: 'icon-minus-circle', message: 'Do Not Disturb is enabled. You will not receive desktop or mobile push notifications until Do Not Disturb is turned off.'},
- {command: '/offline', className: 'icon-circle-outline', message: 'You are now offline'},
- {command: '/online', className: 'icon-check-circle', message: 'You are now online'},
+ {command: '/away', className: 'userAccountMenu_awayMenuItem_icon', message: 'You are now away'},
+ {command: '/dnd', className: 'userAccountMenu_dndMenuItem_icon', message: 'Do Not Disturb is enabled. You will not receive desktop or mobile push notifications until Do Not Disturb is turned off.'},
+ {command: '/offline', className: 'userAccountMenu_offlineMenuItem_icon', message: 'You are now offline'},
+ {command: '/online', className: 'userAccountMenu_onlineMenuItem_icon', message: 'You are now online'},
];
let offTopicUrl;
@@ -67,7 +67,7 @@ describe('Integrations', () => {
function verifyUserStatus(testCase, isCompactMode) {
// * Verify that the user status is as indicated
cy.uiGetProfileHeader().
- find('i').
+ find('svg').
should('be.visible').
and('have.class', testCase.className);
diff --git a/e2e-tests/cypress/tests/integration/channels/integrations/builtin_commands/user_status_spec.js b/e2e-tests/cypress/tests/integration/channels/integrations/builtin_commands/user_status_spec.js
index aadb723205..c5487cfdb6 100644
--- a/e2e-tests/cypress/tests/integration/channels/integrations/builtin_commands/user_status_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/integrations/builtin_commands/user_status_spec.js
@@ -26,14 +26,14 @@ describe('Integrations', () => {
it('MM-T670 /away', () => {
// # Set online status and verify it's changed as the initial status
- setStatus(online.name, online.profileClassName);
+ setStatus(online.name);
verifyUserStatus(away);
});
it('MM-T672 /offline', () => {
// # Set online status and verify it's changed as the initial status
- setStatus(online.name, online.profileClassName);
+ setStatus(online.name);
verifyUserStatus(offline);
@@ -60,17 +60,14 @@ describe('Integrations', () => {
it('MM-T674 /online', () => {
// # Set offline status and verify it's changed as the initial status
- setStatus(offline.name, offline.className);
+ setStatus(offline.name);
verifyUserStatus(online);
});
});
-function setStatus(status, icon) {
+function setStatus(status) {
cy.apiUpdateUserStatus(status);
- cy.uiGetProfileHeader().
- find('i').
- and('have.class', icon);
}
function verifyUserStatus(testCase) {
@@ -90,9 +87,7 @@ function verifyUserStatus(testCase) {
});
// * Verify status shown at user profile in LHS
- cy.uiGetProfileHeader().
- find('i').
- and('have.class', testCase.profileClassName || testCase.className);
+ cy.get('#userAccountMenuButtonDescribedBy').should('exist').and('include.text', `Status is "${testCase.name.charAt(0).toUpperCase() + testCase.name.slice(1)}"`);
// # Post a message
cy.postMessage(testCase.name);
diff --git a/e2e-tests/cypress/tests/integration/channels/menus/main_menu_spec.js b/e2e-tests/cypress/tests/integration/channels/menus/main_menu_spec.js
index 4cf116830a..ff5770ac3c 100644
--- a/e2e-tests/cypress/tests/integration/channels/menus/main_menu_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/menus/main_menu_spec.js
@@ -33,10 +33,9 @@ describe('Main menu', () => {
cy.apiLogin(testUser);
cy.visit(`/${testTeam.name}/channels/town-square`);
- cy.uiOpenUserMenu();
-
- cy.findByRole('menu').should('exist').find('.menu-divider:visible').last().click();
- cy.findByRole('menu').should('exist');
+ cy.uiOpenUserMenu().within(() => {
+ cy.findAllByRole('separator').last().click();
+ });
});
it('should show integrations option for system administrator', () => {
diff --git a/e2e-tests/cypress/tests/integration/channels/menus/status_dropdown_spec.js b/e2e-tests/cypress/tests/integration/channels/menus/status_dropdown_spec.js
index 1d10ee66f0..2e5892b485 100644
--- a/e2e-tests/cypress/tests/integration/channels/menus/status_dropdown_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/menus/status_dropdown_spec.js
@@ -10,15 +10,14 @@
// Stage: @prod
// Group: @channels @menu @custom_status @status_menu
-import * as TIMEOUTS from '../../../fixtures/timeouts';
import theme from '../../../fixtures/theme.json';
describe('Status dropdown menu', () => {
const statusTestCases = [
- {text: 'Online', className: 'icon-check-circle', profileClassName: 'icon-check-circle'},
- {text: 'Away', className: 'icon-clock'},
- {text: 'Do not disturb', className: 'icon-minus-circle'},
- {text: 'Offline', className: 'icon-circle-outline'},
+ {text: 'Online', className: 'userAccountMenu_onlineMenuItem_icon'},
+ {text: 'Away', className: 'userAccountMenu_awayMenuItem_icon'},
+ {text: 'Do not disturb', className: 'userAccountMenu_dndMenuItem_icon'},
+ {text: 'Offline', className: 'userAccountMenu_offlineMenuItem_icon'},
];
before(() => {
@@ -40,9 +39,10 @@ describe('Status dropdown menu', () => {
// * Verify all available statuses are shown with icon and text
statusTestCases.forEach((tc) => {
- cy.get('@userMenu').findByText(tc.text).
- parent().
- find('i').should('have.class', tc.className);
+ cy.uiGetStatusMenu().within(() => {
+ cy.findByText(tc.text).should('be.visible');
+ cy.get('svg').filter(`.${tc.className}`).should('have.length', 1);
+ });
});
});
@@ -67,7 +67,7 @@ describe('Status dropdown menu', () => {
cy.uiOpenUserMenu().as('userMenu');
// * Verify "Set a Custom Status" header is clickable
- cy.get('@userMenu').findByText('Set a custom status').should('have.css', 'cursor', 'pointer');
+ cy.get('@userMenu').findByText('Set custom status').should('have.css', 'cursor', 'pointer');
});
it('MM-T2927_5 When custom status is disabled, status menu is displayed when status icon is clicked', () => {
@@ -81,22 +81,12 @@ describe('Status dropdown menu', () => {
cy.uiOpenUserMenu();
});
- it('MM-T4420 Should stay open when dnd sub-menu header is clicked', () => {
- // # Open Dnd sub menu and click on header
- cy.uiOpenDndStatusSubMenu().find('#dndSubMenu-header_menuitem').click().then(() => {
- cy.wait(TIMEOUTS.HALF_SEC);
-
- // * Verify that dnd submenu is still visible
- cy.get('body').find('#dndSubMenu-header_menuitem').should('be.visible');
- });
- });
-
it('MM-T4914 Profile menu header is clickable, opens Profile settings', () => {
// # Open user menu
- cy.uiOpenUserMenu().as('userMenu');
-
- // * Verify menu header is clickable
- cy.get('@userMenu').get('.MenuHeader').should('have.css', 'cursor', 'pointer').click();
+ cy.uiOpenUserMenu().within(() => {
+ // * Verify menu header is clickable
+ cy.get('li').first().should('have.css', 'cursor', 'pointer').click();
+ });
// * Verify click on header opens Profile settings modal
cy.findByRole('dialog', {name: 'Profile'}).should('be.visible');
@@ -111,14 +101,14 @@ function stepThroughStatuses(statusTestCases = []) {
statusTestCases.forEach((tc) => {
// # Open user menu and click option
if (tc.text === 'Do not disturb') {
- cy.uiOpenDndStatusSubMenu().find('#dndTime-thirty_minutes_menuitem').click();
+ cy.uiOpenDndStatusSubMenuAndClick30Mins();
} else {
cy.uiOpenUserMenu(tc.text);
}
// # Verify correct status icon is shown on user's profile picture
- cy.uiGetProfileHeader().
- find('i').
- and('have.class', tc.profileClassName || tc.className);
+ cy.uiGetSetStatusButton().within(() => {
+ cy.get('svg').filter(`.${tc.className}`).should('have.length', 1);
+ });
});
}
diff --git a/e2e-tests/cypress/tests/integration/channels/notifications/users_with_same_firstname_channel_mentions_spec.js b/e2e-tests/cypress/tests/integration/channels/notifications/users_with_same_firstname_channel_mentions_spec.js
index 4a230d3ec0..6b1045d0e8 100644
--- a/e2e-tests/cypress/tests/integration/channels/notifications/users_with_same_firstname_channel_mentions_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/notifications/users_with_same_firstname_channel_mentions_spec.js
@@ -78,7 +78,7 @@ describe('Notifications', () => {
cy.uiOpenUserMenu().findByText(`@${firstUser.username}`);
// # Close the user menu
- cy.uiGetSetStatusButton().click();
+ cy.get('body').type('{esc}');
// * Check that 'Town Square' is currently being selected
cy.get('.active').within(() => {
diff --git a/e2e-tests/cypress/tests/integration/channels/profile_settings/profile_settings_spec.js b/e2e-tests/cypress/tests/integration/channels/profile_settings/profile_settings_spec.js
index b8dc510ef6..827dcb6ec8 100644
--- a/e2e-tests/cypress/tests/integration/channels/profile_settings/profile_settings_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/profile_settings/profile_settings_spec.js
@@ -71,7 +71,7 @@ describe('Profile Settings', () => {
fileTypes.forEach((fileType, index) => {
it(`MM-T2078_${index + 1} Profile picture: file ${fileType.extension} type accepted`, () => {
// # Save the default profile picture link so it can be compared to the new one
- cy.uiGetProfileHeader().findByRole('img').invoke('attr', 'src').as('defaultProfilePictureLink');
+ cy.uiGetSetStatusButton().get('img').invoke('attr', 'src').as('defaultProfilePictureLink');
cy.uiOpenProfileModal('Profile Settings');
@@ -95,7 +95,7 @@ describe('Profile Settings', () => {
cy.uiClose();
// # Save the new custom profile picture link so it can be compared to the old one
- cy.uiGetProfileHeader().findByRole('img').invoke('attr', 'src').as('customProfilePictureLink');
+ cy.uiGetSetStatusButton().get('img').invoke('attr', 'src').as('customProfilePictureLink');
cy.then(function() {
expect(this.customProfilePictureLink).to.not.equal(this.defaultProfilePictureLink);
diff --git a/e2e-tests/cypress/tests/integration/channels/settings/display/theme/custom_theme_sidebar_styles_spec.js b/e2e-tests/cypress/tests/integration/channels/settings/display/theme/custom_theme_sidebar_styles_spec.js
index 4bf17e1503..ce8c30f1ca 100644
--- a/e2e-tests/cypress/tests/integration/channels/settings/display/theme/custom_theme_sidebar_styles_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/settings/display/theme/custom_theme_sidebar_styles_spec.js
@@ -92,18 +92,18 @@ describe('Custom Theme - Sidebar Styles', () => {
cy.uiOpenUserMenu('Online');
// * Check Online Indicator color
- cy.get('.icon-check-circle').should('have.css', 'color', rgbArrayToString(themeRgbColor.onlineIndicator));
+ cy.uiGetSetStatusButton().find('svg').should('have.css', 'color', rgbArrayToString(themeRgbColor.onlineIndicator));
// # Set user status to away
cy.uiOpenUserMenu('Away');
// * Check Away Indicator color
- cy.get('.icon-clock').should('have.css', 'color', rgbArrayToString(themeRgbColor.awayIndicator));
+ cy.uiGetSetStatusButton().find('svg').should('have.css', 'color', rgbArrayToString(themeRgbColor.awayIndicator));
// # Set user status to do not disturb
- cy.uiOpenDndStatusSubMenu().find('#dndTime-thirty_minutes_menuitem').click();
+ cy.uiOpenDndStatusSubMenuAndClick30Mins();
// * Check Do Not Disturb Indicator color
- cy.get('.icon-minus-circle').should('have.css', 'color', rgbArrayToString(themeRgbColor.dndIndicator));
+ cy.uiGetSetStatusButton().find('svg').should('have.css', 'color', rgbArrayToString(themeRgbColor.dndIndicator));
});
});
diff --git a/e2e-tests/cypress/tests/integration/channels/signin_authentication/login_logout_smoke_spec.js b/e2e-tests/cypress/tests/integration/channels/signin_authentication/login_logout_smoke_spec.js
index 80ad91fa62..c104181236 100644
--- a/e2e-tests/cypress/tests/integration/channels/signin_authentication/login_logout_smoke_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/signin_authentication/login_logout_smoke_spec.js
@@ -42,7 +42,7 @@ describe('SignIn Authentication', () => {
cy.url().should('include', '/channels/town-square');
// # Click logout via user menu
- cy.uiOpenUserMenu('Log Out');
+ cy.uiOpenUserMenu('Log out');
// * Check that it logout successfully and it redirects into the login page
cy.url().should('include', '/login');
diff --git a/e2e-tests/cypress/tests/integration/channels/signin_authentication/login_open_server_spec.js b/e2e-tests/cypress/tests/integration/channels/signin_authentication/login_open_server_spec.js
index c4219a7947..bfb0f199d4 100644
--- a/e2e-tests/cypress/tests/integration/channels/signin_authentication/login_open_server_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/signin_authentication/login_open_server_spec.js
@@ -186,7 +186,7 @@ describe('Login page with open server', () => {
cy.url().should('include', '/channels/town-square');
// # Click logout via user menu
- cy.uiOpenUserMenu('Log Out');
+ cy.uiOpenUserMenu('Log out');
// * Check that it logout successfully and it redirects into the login page
cy.url().should('include', '/login');
@@ -209,7 +209,7 @@ describe('Login page with open server', () => {
cy.url().should('include', '/channels/town-square');
// # Click logout via user menu
- cy.uiOpenUserMenu('Log Out');
+ cy.uiOpenUserMenu('Log out');
// * Check that it logout successfully and it redirects into the login page
cy.url().should('include', '/login');
diff --git a/e2e-tests/cypress/tests/integration/channels/status/status_updates_spec.ts b/e2e-tests/cypress/tests/integration/channels/status/status_updates_spec.ts
index 78b904ce6b..bbe6bbdc18 100644
--- a/e2e-tests/cypress/tests/integration/channels/status/status_updates_spec.ts
+++ b/e2e-tests/cypress/tests/integration/channels/status/status_updates_spec.ts
@@ -92,12 +92,12 @@ describe('Status of current user', () => {
});
function verifyStatus(status: 'online' | 'away' | 'offline' | 'dnd') {
- cy.get('[aria-label="Current status: Online. Select to open profile and status menu."]').
+ cy.get('[aria-label="Status is \\"Online\\". Open user\'s account menu."]').
should(status === 'online' ? 'exist' : 'not.exist');
- cy.get('[aria-label="Current status: Away. Select to open profile and status menu."]').
+ cy.get('[aria-label="Status is \\"Away\\". Open user\'s account menu."]').
should(status === 'away' ? 'exist' : 'not.exist');
- cy.get('[aria-label="Current status: Offline. Select to open profile and status menu."]').
+ cy.get('[aria-label="Status is \\"Offline\\". Open user\'s account menu."]').
should(status === 'offline' ? 'exist' : 'not.exist');
- cy.get('[aria-label="Current status: Do not disturb. Select to open profile and status menu."]').
+ cy.get('[aria-label="Status is \\"Do not disturb\\". Open user\'s account menu."]').
should(status === 'dnd' ? 'exist' : 'not.exist');
}
diff --git a/e2e-tests/cypress/tests/support/common_login_commands.js b/e2e-tests/cypress/tests/support/common_login_commands.js
index 8cf8efc7dd..f732258575 100644
--- a/e2e-tests/cypress/tests/support/common_login_commands.js
+++ b/e2e-tests/cypress/tests/support/common_login_commands.js
@@ -53,7 +53,7 @@ Cypress.Commands.add('checkLeftSideBar', (settings = {}) => {
cy.uiOpenUserMenu().findByText(`@${settings.user.username}`);
// # Close status menu
- cy.uiGetSetStatusButton().click();
+ cy.get('body').type('{esc}');
}
if (settings.user.userType === 'Admin' || settings.user.isAdmin) {
diff --git a/e2e-tests/cypress/tests/support/ui/global_header.js b/e2e-tests/cypress/tests/support/ui/global_header.js
index 64dc75ed45..4800e2841e 100644
--- a/e2e-tests/cypress/tests/support/ui/global_header.js
+++ b/e2e-tests/cypress/tests/support/ui/global_header.js
@@ -27,7 +27,8 @@ Cypress.Commands.add('uiOpenProductMenu', (item = '') => {
});
Cypress.Commands.add('uiGetSetStatusButton', () => {
- return cy.findByRole('button', {name: /Select to open profile and status menu\./i}).should('be.visible');
+ // # Get set status button
+ return cy.get('#userAccountMenuButton').should('be.visible');
});
Cypress.Commands.add('uiGetProfileHeader', () => {
@@ -36,22 +37,18 @@ Cypress.Commands.add('uiGetProfileHeader', () => {
Cypress.Commands.add('uiGetStatusMenuContainer', (options = {exist: true}) => {
if (options.exist) {
- return cy.findByRole('menu').should('exist');
+ return cy.get('#userAccountMenu').should('exist').and('be.visible');
}
- return cy.findByRole('menu').should('not.exist');
+ return cy.get('#userAccountMenu').should('not.exist');
});
Cypress.Commands.add('uiGetStatusMenu', (options = {visible: true}) => {
if (options.visible) {
- return cy.uiGetStatusMenuContainer().
- find('ul').
- should('be.visible');
+ return cy.get('#userAccountMenu').should('exist').and('be.visible');
}
- return cy.uiGetStatusMenuContainer().
- find('ul').
- should('not.be.visible');
+ return cy.get('#userAccountMenu').should('not.exist');
});
Cypress.Commands.add('uiOpenHelpMenu', (item = '') => {
@@ -147,7 +144,7 @@ Cypress.Commands.add('uiOpenSettingsModal', (section = '') => {
Cypress.Commands.add('uiLogout', () => {
// # Click logout via user menu
- cy.uiOpenUserMenu('Log Out');
+ cy.uiOpenUserMenu('Log out');
cy.url().should('include', '/login');
cy.get('.login-body-message').should('be.visible');
diff --git a/e2e-tests/cypress/tests/support/ui/menu.js b/e2e-tests/cypress/tests/support/ui/menu.js
index 689f0e2816..6dbed1dfe2 100644
--- a/e2e-tests/cypress/tests/support/ui/menu.js
+++ b/e2e-tests/cypress/tests/support/ui/menu.js
@@ -33,14 +33,8 @@ Cypress.Commands.add('uiGetSystemConsoleMainMenu', () => {
return getMenu(SYSTEM_CONSOLE_MAIN_MENU);
});
-Cypress.Commands.add('uiOpenDndStatusSubMenu', () => {
- cy.uiOpenUserMenu();
+Cypress.Commands.add('uiOpenDndStatusSubMenuAndClick30Mins', () => {
+ cy.uiOpenUserMenu().findByText('Do not disturb').should('be.visible').trigger('mouseover');
- // # Wait for status menu to transition in
- cy.get('.MenuWrapper.status-dropdown-menu .Menu__content.dropdown-menu').should('be.visible');
-
- // # Hover over Do Not Disturb option
- cy.get('.MenuWrapper.status-dropdown-menu .Menu__content.dropdown-menu li#status-menu-dnd_menuitem').trigger('mouseover');
-
- return cy.get('#status-menu-dnd');
+ cy.findByText('30 mins').should('be.visible').click({force: true});
});
diff --git a/webapp/channels/src/actions/views/status_dropdown.test.ts b/webapp/channels/src/actions/views/status_dropdown.test.ts
deleted file mode 100644
index 0b0da7a309..0000000000
--- a/webapp/channels/src/actions/views/status_dropdown.test.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-import {setStatusDropdown} from 'actions/views/status_dropdown';
-import {isStatusDropdownOpen} from 'selectors/views/status_dropdown';
-import configureStore from 'store';
-
-describe('status_dropdown view actions', () => {
- const initialState = {
- views: {
- statusDropdown: {
- isOpen: false,
- },
- },
- };
-
- it('setStatusDropdown should set the status dropdown open or not', () => {
- const store = configureStore(initialState);
-
- store.dispatch(setStatusDropdown(false));
-
- expect(isStatusDropdownOpen(store.getState())).toBe(false);
-
- store.dispatch(setStatusDropdown(true));
-
- expect(isStatusDropdownOpen(store.getState())).toBe(true);
- });
-});
diff --git a/webapp/channels/src/actions/views/status_dropdown.ts b/webapp/channels/src/actions/views/status_dropdown.ts
deleted file mode 100644
index 93fe4493cd..0000000000
--- a/webapp/channels/src/actions/views/status_dropdown.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-import {ActionTypes} from 'utils/constants';
-
-export function setStatusDropdown(open: boolean) {
- return {
- type: ActionTypes.STATUS_DROPDOWN_TOGGLE,
- open,
- };
-}
diff --git a/webapp/channels/src/components/custom_status/custom_status_emoji.tsx b/webapp/channels/src/components/custom_status/custom_status_emoji.tsx
index daa932298c..5cee41e1e4 100644
--- a/webapp/channels/src/components/custom_status/custom_status_emoji.tsx
+++ b/webapp/channels/src/components/custom_status/custom_status_emoji.tsx
@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React, {memo, useMemo, useRef} from 'react';
+import type {MouseEvent, KeyboardEvent} from 'react';
import {useSelector} from 'react-redux';
import {CustomStatusDuration} from '@mattermost/types/users';
@@ -23,7 +24,7 @@ interface Props {
spanStyle?: React.CSSProperties;
emojiStyle?: React.CSSProperties;
userID?: string;
- onClick?: () => void;
+ onClick?: (event: MouseEvent | KeyboardEvent) => void;
}
function CustomStatusEmoji({
diff --git a/webapp/channels/src/components/custom_status/expiry_time.tsx b/webapp/channels/src/components/custom_status/expiry_time.tsx
index f598b6b85d..283461635c 100644
--- a/webapp/channels/src/components/custom_status/expiry_time.tsx
+++ b/webapp/channels/src/components/custom_status/expiry_time.tsx
@@ -57,7 +57,8 @@ const ExpiryTime = ({time, timezone, className, showPrefix, withinBrackets}: Pro
id='custom_status.expiry.until'
defaultMessage='Until {time}'
values={{time: ''}}
- />{' '}
+ />
+ {' '}
>
);
diff --git a/webapp/channels/src/components/emoji/render_emoji.tsx b/webapp/channels/src/components/emoji/render_emoji.tsx
index c9558c2d09..4ed904a032 100644
--- a/webapp/channels/src/components/emoji/render_emoji.tsx
+++ b/webapp/channels/src/components/emoji/render_emoji.tsx
@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
+import type {MouseEvent, KeyboardEvent} from 'react';
import {useSelector} from 'react-redux';
import {getEmojiImageUrl} from 'mattermost-redux/utils/emoji_utils';
@@ -14,7 +15,7 @@ interface ComponentProps {
emojiName: string;
size?: number;
emojiStyle?: React.CSSProperties;
- onClick?: () => void;
+ onClick?: (event: MouseEvent | KeyboardEvent) => void;
}
const RenderEmoji = ({emojiName, emojiStyle, size, onClick}: ComponentProps) => {
diff --git a/webapp/channels/src/components/global_header/right_controls/right_controls.tsx b/webapp/channels/src/components/global_header/right_controls/right_controls.tsx
index defe1f7a5a..e06c09c0f7 100644
--- a/webapp/channels/src/components/global_header/right_controls/right_controls.tsx
+++ b/webapp/channels/src/components/global_header/right_controls/right_controls.tsx
@@ -9,12 +9,12 @@ import type {ProductIdentifier} from '@mattermost/types/products';
import {isCurrentUserGuestUser} from 'mattermost-redux/selectors/entities/users';
-import StatusDropdown from 'components/status_dropdown';
import {OnboardingTourSteps, OnboardingTourStepsForGuestUsers} from 'components/tours';
import {
CustomizeYourExperienceTour,
useShowOnboardingTutorialStep,
} from 'components/tours/onboarding_tour';
+import UserAccountMenu from 'components/user_account_menu';
import Pluggable from 'plugins/pluggable';
import {isChannels} from 'utils/products';
@@ -46,10 +46,6 @@ const StyledCustomizeYourExperienceTour = styled.div`
height: 100%
`;
-const StyledStatusDropdown = styled.div`
- margin-left: 8px;
-`;
-
export type Props = {
productId?: ProductIdentifier;
}
@@ -87,9 +83,7 @@ const RightControls = ({productId = null}: Props): JSX.Element => {
>
) : null
}
-
-
-
+
);
diff --git a/webapp/channels/src/components/menu/index.ts b/webapp/channels/src/components/menu/index.ts
index 66bbd91468..0f21a10199 100644
--- a/webapp/channels/src/components/menu/index.ts
+++ b/webapp/channels/src/components/menu/index.ts
@@ -1,7 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import './menu.scss';
+
export {Menu as Container} from './menu';
export {SubMenu} from './sub_menu';
export {MenuItem as Item} from './menu_item';
+export type {FirstMenuItemProps} from './menu_item';
export {MenuItemSeparator as Separator} from './menu_item_separator';
diff --git a/webapp/channels/src/components/menu/menu.scss b/webapp/channels/src/components/menu/menu.scss
new file mode 100644
index 0000000000..f8934f0236
--- /dev/null
+++ b/webapp/channels/src/components/menu/menu.scss
@@ -0,0 +1,28 @@
+@use "utils/variables";
+
+.MuiPopover-root.MuiModal-root.menu_menuStyled {
+ z-index: variables.$z-index-menu;
+
+ & .MuiPaper-root {
+ min-width: 114px;
+ max-width: 496px;
+ max-height: 80vh;
+ background-color: var(--center-channel-bg);
+ box-shadow: var(--elevation-4);
+ }
+ &.AsSubMenu {
+ & .MuiPaper-root {
+ box-shadow: var(--elevation-5);
+ }
+ }
+}
+
+.menuModal {
+ & .modal-header {
+ display: none;
+ }
+
+ & .GenericModal__body {
+ padding: 0;
+ }
+}
diff --git a/webapp/channels/src/components/menu/menu.tsx b/webapp/channels/src/components/menu/menu.tsx
index 76f0a8d2a1..48b20602d1 100644
--- a/webapp/channels/src/components/menu/menu.tsx
+++ b/webapp/channels/src/components/menu/menu.tsx
@@ -1,7 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import MuiMenu from '@mui/material/Menu';
import MuiMenuList from '@mui/material/MenuList';
+import type {PopoverOrigin} from '@mui/material/Popover';
+import classNames from 'classnames';
import React, {
useState,
useEffect,
@@ -28,7 +31,8 @@ import Constants, {A11yClassNames} from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import {MenuContext, useMenuContextValue} from './menu_context';
-import {MuiMenuStyled} from './menu_styled';
+
+import './menu.scss';
const MENU_OPEN_ANIMATION_DURATION = 150;
const MENU_CLOSE_ANIMATION_DURATION = 100;
@@ -37,6 +41,7 @@ type MenuButtonProps = {
id: string;
dateTestId?: string;
'aria-label'?: string;
+ 'aria-describedby'?: string;
disabled?: boolean;
class?: string;
as?: keyof JSX.IntrinsicElements;
@@ -55,18 +60,15 @@ type MenuProps = {
'aria-label'?: string;
/**
- * @warning Make the styling of your components such a way that they dont need this handler
+ * @warning Make the styling of your components such a way that they don't need this handler
*/
onToggle?: (isOpen: boolean) => void;
onKeyDown?: (event: KeyboardEvent, forceCloseMenu?: () => void) => void;
width?: string;
}
-const defaultAnchorOrigin = {vertical: 'bottom', horizontal: 'left'};
-const defaultTransformOrigin = {vertical: 'top', horizontal: 'left'};
-
-type VerticalOrigin = 'top' | 'center' | 'bottom';
-type HorizontalOrigin = 'left' | 'center' | 'right';
+const defaultAnchorOrigin = {vertical: 'bottom', horizontal: 'left'} as PopoverOrigin;
+const defaultTransformOrigin = {vertical: 'top', horizontal: 'left'} as PopoverOrigin;
interface Props {
menuButton: MenuButtonProps;
@@ -76,14 +78,8 @@ interface Props {
// Use MUI Anchor Playgroup to try various anchorOrigin
// and transformOrigin values - https://mui.com/material-ui/react-popover/#anchor-playground
- anchorOrigin?: {
- vertical: VerticalOrigin;
- horizontal: HorizontalOrigin;
- };
- transformOrigin?: {
- vertical: VerticalOrigin;
- horizontal: HorizontalOrigin;
- };
+ anchorOrigin?: PopoverOrigin;
+ transformOrigin?: PopoverOrigin;
}
/**
@@ -107,7 +103,7 @@ export function Menu(props: Props) {
const [disableAutoFocusItem, setDisableAutoFocusItem] = useState(false);
const isMenuOpen = Boolean(anchorElement);
- // Callback funtion handler called when menu is closed by escapeKeyDown, backdropClick or tabKeyDown
+ // Callback function handler called when menu is closed by escapeKeyDown, backdropClick or tabKeyDown
function handleMenuClose(event: MouseEvent) {
event.preventDefault();
setAnchorElement(null);
@@ -125,9 +121,9 @@ export function Menu(props: Props) {
setAnchorElement(null);
}
- // Stop sythetic events from bubbling up to the parent
+ // Stop synthetic events from bubbling up to the parent
// @see https://github.com/mui/material-ui/issues/32064
- function handleMenuClick(e: MouseEvent | KeyboardEvent) {
+ function handleMenuClick(e: MouseEvent | KeyboardEvent) {
e.stopPropagation();
}
@@ -193,7 +189,8 @@ export function Menu(props: Props) {
aria-haspopup={true}
aria-expanded={isMenuOpen}
disabled={props.menuButton?.disabled ?? false}
- aria-label={props.menuButton?.['aria-label'] ?? ''}
+ aria-label={props.menuButton?.['aria-label']}
+ aria-describedby={props.menuButton?.['aria-describedby']}
className={props.menuButton?.class ?? ''}
onClick={handleMenuButtonClick}
onMouseDown={handleMenuButtonMouseDown}
@@ -234,21 +231,23 @@ export function Menu(props: Props) {
{renderMenuButton()}
-
{props.children}
-
+
);
diff --git a/webapp/channels/src/components/menu/menu_item.tsx b/webapp/channels/src/components/menu/menu_item.tsx
index a3f766f102..120d36c0bc 100644
--- a/webapp/channels/src/components/menu/menu_item.tsx
+++ b/webapp/channels/src/components/menu/menu_item.tsx
@@ -89,6 +89,25 @@ export interface Props extends MuiMenuItemProps {
children?: ReactNode;
}
+/**
+ * The props for the first menu item to be passed in.
+ * @example
+ *
+ * <-- Container passes the props to the first item
+ *
+ *
+ */
+export type FirstMenuItemProps = Omit<
+Props,
+| 'onClick'
+| 'leadingElement'
+| 'labels'
+| 'trailingElements'
+| 'isDestructive'
+| 'isLabelsRowLayout'
+| 'children'
+>;
+
/**
* To be used as a child of Menu component.
* Checkout Compass's Menu Item(compass.mattermost.com) for terminology, styling and usage guidelines.
@@ -161,7 +180,11 @@ export function MenuItem(props: Props) {
}
// When both primary and secondary labels are passed, we need to apply minor changes to the styling. Check below in styled component for more details.
- const hasSecondaryLabel = labels && labels.props && labels.props.children && Children.count(labels.props.children) === 2;
+ // we count after converting to array as it removes falsy values from labels.props.children
+ const hasSecondaryLabel = labels &&
+ labels.props &&
+ labels.props.children &&
+ Children.count(Children.toArray(labels.props.children)) === 2;
return (
prop !== 'asSubMenu',
-})(
- ({asSubMenu, width}) => ({
- '& .MuiPaper-root': {
- backgroundColor: 'var(--center-channel-bg)',
- boxShadow: `${
- asSubMenu ? 'var(--elevation-5)' : 'var(--elevation-4)'
- }, 0 0 0 1px rgba(var(--center-channel-color-rgb), 0.12) inset`,
- minWidth: '114px',
- maxWidth: '496px',
- maxHeight: '80vh',
- width,
- },
- }),
-);
diff --git a/webapp/channels/src/components/menu/sub_menu.scss b/webapp/channels/src/components/menu/sub_menu.scss
deleted file mode 100644
index b30d419b5c..0000000000
--- a/webapp/channels/src/components/menu/sub_menu.scss
+++ /dev/null
@@ -1,11 +0,0 @@
-.menuModal {
- & .modal-header {
- & > .close {
- visibility: hidden;
- }
- }
-
- & .GenericModal__body {
- padding: 0;
- }
-}
diff --git a/webapp/channels/src/components/menu/sub_menu.tsx b/webapp/channels/src/components/menu/sub_menu.tsx
index 26e3e182c1..a54111f5ea 100644
--- a/webapp/channels/src/components/menu/sub_menu.tsx
+++ b/webapp/channels/src/components/menu/sub_menu.tsx
@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import MuiMenu from '@mui/material/Menu';
import MuiMenuList from '@mui/material/MenuList';
import type {PopoverOrigin} from '@mui/material/Popover';
import React, {
@@ -31,9 +32,6 @@ import {isKeyPressed} from 'utils/keyboard';
import {SubMenuContext, useMenuContextValue} from './menu_context';
import {MenuItem} from './menu_item';
import type {Props as MenuItemProps} from './menu_item';
-import {MuiMenuStyled} from './menu_styled';
-
-import './sub_menu.scss';
interface Props {
id: MenuItemProps['id'];
@@ -41,10 +39,12 @@ interface Props {
labels: MenuItemProps['labels'];
trailingElements?: MenuItemProps['trailingElements'];
isDestructive?: MenuItemProps['isDestructive'];
+ role?: MenuItemProps['role'];
// Menu props
menuId: string;
menuAriaLabel?: string;
+ menuAriaDescribedBy?: string;
forceOpenOnLeft?: boolean; // Most of the times this is not needed, since submenu position is calculated and placed
children: ReactNode;
@@ -57,8 +57,10 @@ export function SubMenu(props: Props) {
labels,
trailingElements,
isDestructive,
+ role,
menuId,
menuAriaLabel,
+ menuAriaDescribedBy,
forceOpenOnLeft,
children,
...rest
@@ -148,6 +150,7 @@ export function SubMenu(props: Props) {
labels,
trailingElements,
isDestructive,
+ role,
onClick: isMobileView ? handleOnClick : undefined, // OnClicks on parent menuItem of subMenu is only needed in mobile view
};
@@ -163,13 +166,13 @@ export function SubMenu(props: Props) {
onMouseLeave={handleMouseLeave}
onKeyDown={handleKeyDown}
>
-
{/* This component is needed here to re enable pointer events for the submenu items which we had to disable above as */}
{/* pointer turns to default as soon as it leaves the parent menu */}
@@ -178,6 +181,7 @@ export function SubMenu(props: Props) {
id={menuId}
component='ul'
aria-label={menuAriaLabel}
+ aria-describedby={menuAriaDescribedBy}
className={A11yClassNames.POPUP}
onKeyDown={handleSubMenuKeyDown}
sx={{
@@ -190,7 +194,7 @@ export function SubMenu(props: Props) {
{children}
-
+
);
}
diff --git a/webapp/channels/src/components/onboarding_tasks/complete_your_profile_tour_tip.tsx b/webapp/channels/src/components/onboarding_tasks/complete_your_profile_tour_tip.tsx
index b8682c9356..369db76804 100644
--- a/webapp/channels/src/components/onboarding_tasks/complete_your_profile_tour_tip.tsx
+++ b/webapp/channels/src/components/onboarding_tasks/complete_your_profile_tour_tip.tsx
@@ -43,7 +43,7 @@ export const CompleteYourProfileTour = () => {
);
- const overlayPunchOut = useMeasurePunchouts(['status-drop-down-menu-list'], [], {y: -6, height: 6, x: 0, width: 0});
+ const overlayPunchOut = useMeasurePunchouts(['userAccountMenu'], [], {y: -6, height: 6, x: 0, width: 0});
const onDismiss = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
@@ -64,6 +64,7 @@ export const CompleteYourProfileTour = () => {
singleTip={true}
showOptOut={false}
interactivePunchOut={true}
+ zIndex={1058}
/>
);
};
diff --git a/webapp/channels/src/components/onboarding_tasks/onboarding_tasks_manager.tsx b/webapp/channels/src/components/onboarding_tasks/onboarding_tasks_manager.tsx
index 3cca6f7f4e..011346935e 100644
--- a/webapp/channels/src/components/onboarding_tasks/onboarding_tasks_manager.tsx
+++ b/webapp/channels/src/components/onboarding_tasks/onboarding_tasks_manager.tsx
@@ -20,7 +20,6 @@ import {
switchToChannels,
} from 'actions/views/onboarding_tasks';
import {setProductMenuSwitcherOpen} from 'actions/views/product_menu';
-import {setStatusDropdown} from 'actions/views/status_dropdown';
import {getOnboardingTaskPreferences} from 'selectors/onboarding';
import Channels from 'components/common/svg_images_components/channels_svg';
@@ -40,6 +39,7 @@ import {
} from 'components/tours';
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
+import {clickOnMenuButton, MenuButtonIds} from 'utils/keyboard';
import type {GlobalState} from 'types/store';
@@ -239,7 +239,7 @@ export const useHandleOnBoardingTaskTrigger = () => {
break;
}
case OnboardingTasksName.COMPLETE_YOUR_PROFILE: {
- dispatch(setStatusDropdown(true));
+ clickOnMenuButton(MenuButtonIds.userAccountMenu);
dispatch(setShowOnboardingCompleteProfileTour(true));
handleSaveData(taskName, TaskNameMapToSteps[taskName].STARTED, true);
if (inAdminConsole) {
diff --git a/webapp/channels/src/components/post_view/post_header_custom_status/post_header_custom_status.tsx b/webapp/channels/src/components/post_view/post_header_custom_status/post_header_custom_status.tsx
index 175d1b7526..79231ea9b6 100644
--- a/webapp/channels/src/components/post_view/post_header_custom_status/post_header_custom_status.tsx
+++ b/webapp/channels/src/components/post_view/post_header_custom_status/post_header_custom_status.tsx
@@ -3,14 +3,15 @@
import React, {useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
-import {useDispatch, useSelector} from 'react-redux';
+import {useSelector} from 'react-redux';
-import {setStatusDropdown} from 'actions/views/status_dropdown';
import {makeGetCustomStatus, showPostHeaderUpdateStatusButton, isCustomStatusEnabled} from 'selectors/views/custom_status';
import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
import EmojiIcon from 'components/widgets/icons/emoji_icon';
+import {clickOnMenuButton, MenuButtonIds} from 'utils/keyboard';
+
import type {GlobalState} from 'types/store';
interface ComponentProps {
@@ -22,7 +23,6 @@ interface ComponentProps {
const PostHeaderCustomStatus = (props: ComponentProps) => {
const getCustomStatus = useMemo(makeGetCustomStatus, []);
const {userId, isSystemMessage, isBot} = props;
- const dispatch = useDispatch();
const userCustomStatus = useSelector((state: GlobalState) => getCustomStatus(state, userId));
const showUpdateStatusButton = useSelector(showPostHeaderUpdateStatusButton);
const customStatusEnabled = useSelector(isCustomStatusEnabled);
@@ -51,7 +51,7 @@ const PostHeaderCustomStatus = (props: ComponentProps) => {
const updateStatus = (event: React.MouseEvent) => {
event.preventDefault();
- dispatch(setStatusDropdown(true));
+ clickOnMenuButton(MenuButtonIds.userAccountMenu);
};
return (
diff --git a/webapp/channels/src/components/status_dropdown/__snapshots__/status_dropdown.test.tsx.snap b/webapp/channels/src/components/status_dropdown/__snapshots__/status_dropdown.test.tsx.snap
deleted file mode 100644
index ebe0286e81..0000000000
--- a/webapp/channels/src/components/status_dropdown/__snapshots__/status_dropdown.test.tsx.snap
+++ /dev/null
@@ -1,2552 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`components/StatusDropdown should match snapshot in default state 1`] = `
-
-
-
-
-`;
-
-exports[`components/StatusDropdown should match snapshot with custom status and expiry 1`] = `
-
-
-
-
-`;
-
-exports[`components/StatusDropdown should match snapshot with custom status enabled 1`] = `
-
-
-
-
-`;
-
-exports[`components/StatusDropdown should match snapshot with custom status expired 1`] = `
-
-
-
-
-`;
-
-exports[`components/StatusDropdown should match snapshot with custom status pulsating dot enabled 1`] = `
-
-
-
-
-`;
-
-exports[`components/StatusDropdown should match snapshot with profile picture URL 1`] = `
-
-
-
-
-`;
-
-exports[`components/StatusDropdown should match snapshot with status dropdown open 1`] = `
-
-
-
-
-`;
-
-exports[`components/StatusDropdown should not show clear status button when custom status is not set 1`] = `
-
-
-
-
-`;
-
-exports[`components/StatusDropdown should show clear status button when custom status is set 1`] = `
-
-
-
-
-`;
diff --git a/webapp/channels/src/components/status_dropdown/index.ts b/webapp/channels/src/components/status_dropdown/index.ts
deleted file mode 100644
index 7a92fe9d58..0000000000
--- a/webapp/channels/src/components/status_dropdown/index.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-import {connect} from 'react-redux';
-import {bindActionCreators} from 'redux';
-import type {Dispatch} from 'redux';
-
-import {savePreferences} from 'mattermost-redux/actions/preferences';
-import {setStatus, unsetCustomStatus} from 'mattermost-redux/actions/users';
-import {Client4} from 'mattermost-redux/client';
-import {Preferences} from 'mattermost-redux/constants';
-import {get, getBool, getInt} from 'mattermost-redux/selectors/entities/preferences';
-import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
-import {getCurrentUser, getDndEndTimeForUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
-
-import {openModal} from 'actions/views/modals';
-import {setStatusDropdown} from 'actions/views/status_dropdown';
-import {makeGetCustomStatus, isCustomStatusEnabled, showStatusDropdownPulsatingDot, isCustomStatusExpired} from 'selectors/views/custom_status';
-import {isStatusDropdownOpen} from 'selectors/views/status_dropdown';
-
-import {
- OnboardingTaskCategory,
- OnboardingTasksName,
- TaskNameMapToSteps,
-} from 'components/onboarding_tasks';
-
-import type {GlobalState} from 'types/store';
-
-import StatusDropdown from './status_dropdown';
-
-function makeMapStateToProps() {
- const getCustomStatus = makeGetCustomStatus();
-
- return function mapStateToProps(state: GlobalState) {
- const currentUser = getCurrentUser(state);
-
- const userId = currentUser?.id;
- const customStatus = getCustomStatus(state, userId);
- const isMilitaryTime = getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, false);
- const step = getInt(state, OnboardingTaskCategory, OnboardingTasksName.COMPLETE_YOUR_PROFILE, 0);
- const showCompleteYourProfileTour = step === TaskNameMapToSteps[OnboardingTasksName.COMPLETE_YOUR_PROFILE].STARTED;
- return {
- userId,
- profilePicture: Client4.getProfilePictureUrl(userId, currentUser?.last_picture_update),
- autoResetPref: get(state, Preferences.CATEGORY_AUTO_RESET_MANUAL_STATUS, userId, ''),
- status: getStatusForUserId(state, userId),
- customStatus,
- currentUser,
- isCustomStatusEnabled: isCustomStatusEnabled(state),
- isCustomStatusExpired: isCustomStatusExpired(state, customStatus),
- isMilitaryTime,
- isStatusDropdownOpen: isStatusDropdownOpen(state),
- showCustomStatusPulsatingDot: showStatusDropdownPulsatingDot(state),
- showCompleteYourProfileTour,
- timezone: getCurrentTimezone(state),
- dndEndTime: getDndEndTimeForUserId(state, userId),
- };
- };
-}
-
-function mapDispatchToProps(dispatch: Dispatch) {
- return {
- actions: bindActionCreators({
- openModal,
- setStatus,
- unsetCustomStatus,
- setStatusDropdown,
- savePreferences,
- }, dispatch),
- };
-}
-
-export default connect(makeMapStateToProps, mapDispatchToProps)(StatusDropdown);
diff --git a/webapp/channels/src/components/status_dropdown/status_dropdown.scss b/webapp/channels/src/components/status_dropdown/status_dropdown.scss
deleted file mode 100644
index 9a7c27c652..0000000000
--- a/webapp/channels/src/components/status_dropdown/status_dropdown.scss
+++ /dev/null
@@ -1,358 +0,0 @@
-.logout__icon {
- &:hover {
- fill: rgba(255, 0, 0, 0.75);
- }
-}
-
-#statusDropdownMenu {
- .icon {
- position: relative;
- display: inline-flex;
- width: 24px;
- height: 24px;
- justify-content: center;
- padding: 0;
- margin: 0 8px 0 0;
-
- span {
- display: flex;
- }
-
- svg {
- width: 16px;
- height: 16px;
- }
- }
-
- #status-menu-dnd {
- padding: 4px 16px;
-
- .dropdown-menu {
- margin: auto;
-
- #dndSubMenu-header {
- font-weight: bold;
- pointer-events: none;
- }
- }
-
- .icon {
- top: 2px;
- }
-
- .SubMenu__icon-right {
- padding-top: 5px;
- }
- }
-}
-
-#status-menu-dnd_menuitem {
- .SubMenuItem.MenuItem {
- flex-grow: 1;
- }
-
- .SubMenuItemContainer {
- &:hover,
- &:focus {
- background-color: unset;
- }
- }
-
- #dndTime-tomorrow .dndTime-tomorrow_timestamp {
- &::before {
- content: "\00a0";
- }
-
- margin-left: auto;
- opacity: 0.7;
- }
-}
-
-button.custom_status__row {
- position: relative;
- height: auto;
- font-size: inherit;
-
- &.flex {
- display: flex;
- }
-
- &:hover {
- background-color: rgba(var(--center-channel-color-rgb), 0.08);
-
- .custom-status-emoji {
- fill: rgba(var(--center-channel-color-rgb), 0.75);
- }
- }
-
- &:active {
- background-color: rgba(var(--button-bg-rgb), 0.08);
- }
-}
-
-.custom_status {
- &__container {
- display: flex;
- padding: 7px 0;
- color: rgba(var(--center-channel-color-rgb), 1);
- line-height: 20px;
- }
-
- &__icon {
- margin-right: 12px;
- }
-
- &__text {
- overflow: hidden;
- max-width: 73%;
- height: 16px;
- line-height: 16px;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- &__expiry {
- font-size: 12px;
- line-height: 16px;
- opacity: 0.7;
-
- &.padded {
- display: block;
- padding-left: 28px;
- }
- }
-}
-
-#custom_status__clear {
- position: absolute;
- top: 0;
- right: 10px;
- width: auto;
- padding: 0;
- color: rgba(var(--center-channel-color-rgb), 0.52);
-
- &:hover {
- background: transparent;
- }
-}
-
-.custom-status {
- &-emoji {
- fill: rgba(var(--center-channel-color-rgb), 0.75);
-
- &:hover {
- fill: rgba(var(--center-channel-color-rgb), 0.88);
- }
- }
-
- &-suggestion-emoji > span:first-child {
- background-size: 20px;
- }
-}
-
-.status-dropdown-menu-global-header {
- padding: 4px 8px;
- border-radius: 4px;
-
- &:hover {
- background: var(--sidebar-text-08);
- cursor: pointer;
-
- .status {
- background-image:
- linear-gradient(
- 0deg,
- rgba(var(--global-header-text-rgb), 0.08),
- rgba(var(--global-header-text-rgb), 0.08)
- );
- }
- }
-
- &.active {
- background: rgba(var(--global-header-text-rgb), 0.16);
-
- .status {
- background-image:
- linear-gradient(
- 0deg,
- rgba(var(--global-header-text-rgb), 0.16),
- rgba(var(--global-header-text-rgb), 0.16)
- );
- }
- }
-
- .Menu__content {
- right: -1px;
- left: inherit;
- width: 264px;
- }
-
- .custom_status__row > #custom_status__clear {
- top: 4px;
- }
-
- .custom_status__container {
- align-items: center;
- color: rgba(var(--center-channel-color-rgb), 0.75);
- line-height: 14px;
- }
-
- .username-wrapper {
- padding: 0 12px;
-
- > p {
- overflow: hidden;
- max-width: 150px;
- text-overflow: ellipsis;
- white-space: nowrap;
-
- &.contrast {
- color: rgba(var(--center-channel-color-rgb), 0.75);
- }
-
- &.bold {
- font-family: 'Metropolis', sans-serif;
- font-size: 16px;
- font-weight: 600;
- }
- }
- }
-
- .MenuHeader {
- height: 50px;
- padding: 0 16px;
- cursor: pointer;
-
- &:hover {
- background-color: rgba(var(--center-channel-color-rgb), 0.1);
- }
- }
-
- .MenuItem > button,
- #status-menu-dnd {
- padding: 1px 16px;
-
- .flex {
- display: flex;
- }
-
- .MenuItem__primary-text,
- .grid {
- display: grid;
- align-items: center;
- grid-template-columns: auto 1fr auto auto auto;
-
- .SubMenu__icon-right {
- padding-top: 0;
- padding-left: 12px;
- }
- }
-
- > .MenuItem__help-text {
- padding-left: 28px;
- }
- }
-
- #statusDropdownMenu,
- #statusDropdownMenu #status-menu-dnd {
- .icon {
- top: unset;
- width: auto;
- height: auto;
- padding: 0 12px 0 0;
- margin: 0;
- }
-
- .SubMenu__icon-right {
- padding-top: 0;
- }
- }
-
- .dnd--icon {
- vertical-align: middle;
- }
-
- .status-wrapper {
- display: flex;
- height: auto;
- align-items: center;
-
- .status {
- position: absolute;
- top: auto;
- right: -4px;
- bottom: -4px;
- display: inline-flex;
- width: 16px;
- height: 16px;
- align-items: center;
- justify-content: center;
- background-color: var(--global-header-background);
- color: var(--global-header-text);
-
- &:hover {
- cursor: pointer;
- }
-
- svg {
- top: 0;
- }
- }
- }
-}
-
-@keyframes pulse1 {
- 0% {
- opacity: 1;
- transform: scale(1);
- }
-
- 80% {
- opacity: 0;
- transform: scale(2.5);
- }
-
- 100% {
- opacity: 0;
- transform: scale(2.5);
- }
-}
-
-@keyframes pulse2 {
- 0% {
- opacity: 1;
- transform: scale(1);
- }
-
- 30% {
- opacity: 1;
- transform: scale(1);
- }
-
- 100% {
- opacity: 0;
- transform: scale(2.5);
- }
-}
-
-.status-dropdpwn-menu .status-dropdown-menu__clear-container .input-clear {
- position: absolute;
- color: rgba(var(--center-channel-color-rgb), 0.64);
- opacity: 1;
-}
-
-.status-dropdpwn-account-settings {
- position: relative;
-}
-
-#accountSettings {
- position: relative;
-}
-
-.account-settings-complete {
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
-}
diff --git a/webapp/channels/src/components/status_dropdown/status_dropdown.test.tsx b/webapp/channels/src/components/status_dropdown/status_dropdown.test.tsx
deleted file mode 100644
index 5ed2737b0c..0000000000
--- a/webapp/channels/src/components/status_dropdown/status_dropdown.test.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-import React from 'react';
-
-import {CustomStatusDuration} from '@mattermost/types/users';
-import type {UserProfile} from '@mattermost/types/users';
-
-import {fakeDate} from 'tests/helpers/date';
-import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
-
-import StatusDropdown from './status_dropdown';
-
-describe('components/StatusDropdown', () => {
- let resetFakeDate: () => void;
-
- beforeEach(() => {
- resetFakeDate = fakeDate(new Date('2021-11-02T22:48:57Z'));
- });
-
- afterEach(() => {
- resetFakeDate();
- });
-
- const actions = {
- openModal: jest.fn(),
- setStatus: jest.fn(),
- unsetCustomStatus: jest.fn(),
- setStatusDropdown: jest.fn(),
- savePreferences: jest.fn(),
- };
-
- const baseProps = {
- actions,
- userId: '',
- currentUser: {
- id: 'user_id',
- first_name: 'Nev',
- last_name: 'Aa',
- } as UserProfile,
- userTimezone: {
- useAutomaticTimezone: 'true',
- automaticTimezone: 'America/New_York',
- manualTimezone: '',
- },
- status: 'away',
- isMilitaryTime: false,
- isCustomStatusEnabled: false,
- isCustomStatusExpired: false,
- isStatusDropdownOpen: false,
- showCustomStatusPulsatingDot: false,
- showCompleteYourProfileTour: false,
- };
-
- test('should match snapshot in default state', () => {
- const wrapper = shallowWithIntl(
- ,
- );
- expect(wrapper).toMatchSnapshot();
- });
-
- test('should match snapshot with profile picture URL', () => {
- const props = {
- ...baseProps,
- profilePicture: 'http://localhost:8065/api/v4/users/jsx5jmdiyjyuzp9rzwfaf5pwjo/image?_=1590519110944',
- };
-
- const wrapper = shallowWithIntl(
- ,
- );
- expect(wrapper).toMatchSnapshot();
- });
-
- test('should match snapshot with status dropdown open', () => {
- const props = {
- ...baseProps,
- isStatusDropdownOpen: true,
- };
-
- const wrapper = shallowWithIntl(
- ,
- );
- expect(wrapper).toMatchSnapshot();
- });
-
- test('should match snapshot with custom status enabled', () => {
- const props = {
- ...baseProps,
- isStatusDropdownOpen: true,
- isCustomStatusEnabled: true,
- };
-
- const wrapper = shallowWithIntl(
- ,
- );
- expect(wrapper).toMatchSnapshot();
- });
-
- test('should match snapshot with custom status pulsating dot enabled', () => {
- const props = {
- ...baseProps,
- isStatusDropdownOpen: true,
- isCustomStatusEnabled: true,
- showCustomStatusPulsatingDot: true,
- };
-
- const wrapper = shallowWithIntl(
- ,
- );
- expect(wrapper).toMatchSnapshot();
- });
-
- test('should match snapshot with custom status and expiry', () => {
- const customStatus = {
- emoji: 'calendar',
- text: 'In a meeting',
- duration: CustomStatusDuration.TODAY,
- expires_at: '2021-05-03T23:59:59.000Z',
- };
- const props = {
- ...baseProps,
- isStatusDropdownOpen: true,
- isCustomStatusEnabled: true,
- customStatus,
- };
-
- const wrapper = shallowWithIntl(
- ,
- );
- expect(wrapper).toMatchSnapshot();
- });
-
- test('should match snapshot with custom status expired', () => {
- const customStatus = {
- emoji: 'calendar',
- text: 'In a meeting',
- duration: CustomStatusDuration.TODAY,
- expires_at: '2021-05-03T23:59:59.000Z',
- };
- const props = {
- ...baseProps,
- isStatusDropdownOpen: true,
- isCustomStatusEnabled: true,
- isCustomStatusExpired: true,
- customStatus,
- };
-
- const wrapper = shallowWithIntl(
- ,
- );
- expect(wrapper).toMatchSnapshot();
- });
-
- test('should show clear status button when custom status is set', () => {
- const customStatus = {
- emoji: 'calendar',
- text: 'In a meeting',
- duration: CustomStatusDuration.TODAY,
- expires_at: '2021-05-03T23:59:59.000Z',
- };
- const props = {
- ...baseProps,
- isStatusDropdownOpen: true,
- isCustomStatusEnabled: true,
- isCustomStatusExpired: false,
- customStatus,
- };
-
- const wrapper = shallowWithIntl(
- ,
- );
-
- expect(wrapper.find('.status-dropdown-menu__clear-container').exists()).toBe(true);
- expect(wrapper).toMatchSnapshot();
- });
-
- test('should not show clear status button when custom status is not set', () => {
- const customStatus = undefined;
- const props = {
- ...baseProps,
- isStatusDropdownOpen: true,
- isCustomStatusEnabled: true,
- isCustomStatusExpired: false,
- customStatus,
- };
-
- const wrapper = shallowWithIntl(
- ,
- );
-
- expect(wrapper.find('.status-dropdown-menu__clear-container').exists()).toBe(false);
- expect(wrapper).toMatchSnapshot();
- });
-});
diff --git a/webapp/channels/src/components/status_dropdown/status_dropdown.tsx b/webapp/channels/src/components/status_dropdown/status_dropdown.tsx
deleted file mode 100644
index 7543f54402..0000000000
--- a/webapp/channels/src/components/status_dropdown/status_dropdown.tsx
+++ /dev/null
@@ -1,701 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-import classNames from 'classnames';
-import moment from 'moment-timezone';
-import React from 'react';
-import type {ReactNode} from 'react';
-import {injectIntl, FormattedDate, FormattedMessage, FormattedTime, defineMessage, defineMessages} from 'react-intl';
-import type {IntlShape, MessageDescriptor} from 'react-intl';
-
-import StatusIcon from '@mattermost/compass-components/components/status-icon'; // eslint-disable-line no-restricted-imports
-import Text from '@mattermost/compass-components/components/text'; // eslint-disable-line no-restricted-imports
-import type {TUserStatus} from '@mattermost/compass-components/shared'; // eslint-disable-line no-restricted-imports
-import {AccountOutlineIcon, CancelIcon, CheckIcon, ExitToAppIcon} from '@mattermost/compass-icons/components';
-import {PulsatingDot} from '@mattermost/components';
-import type {PreferenceType} from '@mattermost/types/preferences';
-import {CustomStatusDuration} from '@mattermost/types/users';
-import type {UserCustomStatus, UserProfile, UserStatus} from '@mattermost/types/users';
-
-import * as GlobalActions from 'actions/global_actions';
-
-import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
-import CustomStatusModal from 'components/custom_status/custom_status_modal';
-import CustomStatusText from 'components/custom_status/custom_status_text';
-import ExpiryTime from 'components/custom_status/expiry_time';
-import DndCustomTimePicker from 'components/dnd_custom_time_picker_modal';
-import {OnboardingTaskCategory, OnboardingTasksName, TaskNameMapToSteps, CompleteYourProfileTour} from 'components/onboarding_tasks';
-import ResetStatusModal from 'components/reset_status_modal';
-import UserSettingsModal from 'components/user_settings/modal';
-import EmojiIcon from 'components/widgets/icons/emoji_icon';
-import Menu from 'components/widgets/menu/menu';
-import MenuWrapper from 'components/widgets/menu/menu_wrapper';
-import Avatar from 'components/widgets/users/avatar/avatar';
-import type {TAvatarSizeToken} from 'components/widgets/users/avatar/avatar';
-import WithTooltip from 'components/with_tooltip';
-
-import {ModalIdentifiers, UserStatuses} from 'utils/constants';
-import {getBrowserTimezone, getCurrentDateTimeForTimezone, getCurrentMomentForTimezone} from 'utils/timezone';
-
-import type {ModalData} from 'types/actions';
-import type {Menu as MenuType} from 'types/store/plugins';
-
-import './status_dropdown.scss';
-
-type Props = {
- intl: IntlShape;
- status?: string;
- userId: string;
- profilePicture?: string;
- autoResetPref?: string;
- actions: {
- openModal: (modalData: ModalData
) => void;
- setStatus: (status: UserStatus) => void;
- unsetCustomStatus: () => void;
- savePreferences: (userId: string, preferences: PreferenceType[]) => void;
- setStatusDropdown: (open: boolean) => void;
- };
- customStatus?: UserCustomStatus;
- currentUser: UserProfile;
- isCustomStatusEnabled: boolean;
- isCustomStatusExpired: boolean;
- isMilitaryTime: boolean;
- isStatusDropdownOpen: boolean;
- showCompleteYourProfileTour: boolean;
- showCustomStatusPulsatingDot: boolean;
- timezone?: string;
- dndEndTime?: number;
-}
-
-type State = {
- openUp: boolean;
- width: number;
- isStatusSet: boolean;
-};
-
-export const statusDropdownMessages: Record> = {
- ooo: defineMessages({
- name: {
- id: 'status_dropdown.set_ooo',
- defaultMessage: 'Out of office',
- },
- extra: {
- id: 'status_dropdown.set_ooo.extra',
- defaultMessage: 'Automatic Replies are enabled',
- },
- }),
- online: defineMessages({
- name: {
- id: 'status_dropdown.set_online',
- defaultMessage: 'Online',
- },
- }),
- away: defineMessages({
- name: {
- id: 'status_dropdown.set_away',
- defaultMessage: 'Away',
- },
- }),
- dnd: defineMessages({
- name: {
- id: 'status_dropdown.set_dnd',
- defaultMessage: 'Do not disturb',
- },
- }),
- offline: defineMessages({
- name: {
- id: 'status_dropdown.set_offline',
- defaultMessage: 'Offline',
- },
- }),
-};
-
-export class StatusDropdown extends React.PureComponent {
- dndTimes = [
- {id: 'dont_clear', label: defineMessage({id: 'status_dropdown.dnd_sub_menu_item.dont_clear', defaultMessage: 'Don\'t clear'})},
- {id: 'thirty_minutes', label: defineMessage({id: 'status_dropdown.dnd_sub_menu_item.thirty_minutes', defaultMessage: '30 mins'})},
- {id: 'one_hour', label: defineMessage({id: 'status_dropdown.dnd_sub_menu_item.one_hour', defaultMessage: '1 hour'})},
- {id: 'two_hours', label: defineMessage({id: 'status_dropdown.dnd_sub_menu_item.two_hours', defaultMessage: '2 hours'})},
- {id: 'tomorrow', label: defineMessage({id: 'status_dropdown.dnd_sub_menu_item.tomorrow', defaultMessage: 'Tomorrow'})},
- {id: 'custom', label: defineMessage({id: 'status_dropdown.dnd_sub_menu_item.custom', defaultMessage: 'Choose date and time'})},
- ];
- static defaultProps = {
- userId: '',
- profilePicture: '',
- };
-
- constructor(props: Props) {
- super(props);
-
- this.state = {
- openUp: false,
- width: 0,
- isStatusSet: false,
- };
- }
-
- openProfileModal = (): void => {
- this.props.actions.openModal({
- modalId: ModalIdentifiers.USER_SETTINGS,
- dialogType: UserSettingsModal,
- dialogProps: {isContentProductSettings: false},
- });
- };
-
- setStatus = (status: string, dndEndTime?: number): void => {
- this.props.actions.setStatus({
- user_id: this.props.userId,
- status,
- dnd_end_time: dndEndTime,
- });
- };
-
- isUserOutOfOffice = (): boolean => {
- return this.props.status === UserStatuses.OUT_OF_OFFICE;
- };
-
- setOnline = (event: Event): void => {
- event.preventDefault();
- this.setStatus(UserStatuses.ONLINE);
- };
-
- setOffline = (event: Event): void => {
- event.preventDefault();
- this.setStatus(UserStatuses.OFFLINE);
- };
-
- setAway = (event: Event): void => {
- event.preventDefault();
- this.setStatus(UserStatuses.AWAY);
- };
-
- setDnd = (index: number): void => {
- const currentDate = getCurrentMomentForTimezone(this.props.timezone);
- let endTime = currentDate;
- switch (index) {
- case 0:
- endTime = moment(0);
- break;
- case 1:
- // add 30 minutes in current time
- endTime = currentDate.add(30, 'minutes');
- break;
- case 2:
- // add 1 hour in current time
- endTime = currentDate.add(1, 'hour');
- break;
- case 3:
- // add 2 hours in current time
- endTime = currentDate.add(2, 'hours');
- break;
- case 4:
- // set to next day 9 in the morning
- endTime = currentDate.add(1, 'day').set({hour: 9, minute: 0});
- break;
- }
-
- this.setStatus(UserStatuses.DND, endTime.utc().unix());
- };
-
- setCustomTimedDnd = (): void => {
- const dndCustomTimePicker = {
- modalId: ModalIdentifiers.DND_CUSTOM_TIME_PICKER,
- dialogType: DndCustomTimePicker,
- dialogProps: {
- currentDate: this.props.timezone ? getCurrentDateTimeForTimezone(this.props.timezone) : new Date(),
- },
- };
-
- this.props.actions.openModal(dndCustomTimePicker);
- };
-
- showStatusChangeConfirmation = (status: string): void => {
- const resetStatusModalData = {
- modalId: ModalIdentifiers.RESET_STATUS,
- dialogType: ResetStatusModal,
- dialogProps: {newStatus: status},
- };
-
- this.props.actions.openModal(resetStatusModalData);
- };
-
- renderProfilePicture = (size: TAvatarSizeToken): ReactNode => {
- if (!this.props.profilePicture) {
- return null;
- }
- return (
-
- );
- };
-
- handleClearStatus = (e: React.MouseEvent | React.MouseEvent | React.TouchEvent): void => {
- e.stopPropagation();
- e.preventDefault();
- this.props.actions.unsetCustomStatus();
- };
-
- handleEmitUserLoggedOutEvent = (): void => {
- GlobalActions.emitUserLoggedOutEvent();
- };
-
- onToggle = (open: boolean): void => {
- this.props.actions.setStatusDropdown(open);
- };
-
- handleCompleteYourProfileTask = (): void => {
- const taskName = OnboardingTasksName.COMPLETE_YOUR_PROFILE;
- const steps = TaskNameMapToSteps[taskName];
- const currentUserId = this.props.currentUser.id;
- const preferences = [
- {
- user_id: currentUserId,
- category: OnboardingTaskCategory,
- name: taskName,
- value: steps.FINISHED.toString(),
- },
- ];
- this.props.actions.savePreferences(currentUserId, preferences);
- };
-
- handleCustomStatusEmojiClick = (event: React.MouseEvent): void => {
- event.stopPropagation();
- const customStatusInputModalData = {
- modalId: ModalIdentifiers.CUSTOM_STATUS,
- dialogType: CustomStatusModal,
- };
- this.props.actions.openModal(customStatusInputModalData);
- };
-
- renderCustomStatus = (isStatusSet: boolean | undefined): ReactNode => {
- if (!this.props.isCustomStatusEnabled) {
- return null;
- }
- const {customStatus} = this.props;
-
- let customStatusText;
- let customStatusHelpText;
- switch (true) {
- case isStatusSet && customStatus?.text && customStatus.text.length > 0:
- customStatusText = customStatus?.text;
- break;
- case isStatusSet && !customStatus?.text && customStatus?.duration === CustomStatusDuration.DONT_CLEAR:
- customStatusHelpText = this.props.intl.formatMessage({id: 'status_dropdown.set_custom_text', defaultMessage: 'Set custom status text...'});
- break;
- case isStatusSet && !customStatus?.text && customStatus?.duration !== CustomStatusDuration.DONT_CLEAR:
- customStatusText = '';
- break;
- case !isStatusSet:
- customStatusHelpText = this.props.intl.formatMessage({id: 'status_dropdown.set_custom', defaultMessage: 'Set a custom status'});
- }
-
- const customStatusEmoji = isStatusSet ? (
-
-
-
- ) : (
-
- );
-
- const pulsatingDot = !isStatusSet && this.props.showCustomStatusPulsatingDot && (
-
- );
-
- const clearableTooltipText = (
-
- );
-
- const clearButton = isStatusSet && !pulsatingDot && (
-
-
-
-
-
-
-
- );
-
- const expiryTime = isStatusSet && customStatus?.expires_at && customStatus.duration !== CustomStatusDuration.DONT_CLEAR &&
- (
- 0,
- })}
- withinBrackets={true}
- />
- );
-
- return (
-
-
-
- );
- };
-
- renderDndExtraText = (dndEndTime?: number, timezone?: string) => {
- if (!(dndEndTime && dndEndTime > 0)) {
- return this.props.intl.formatMessage({id: 'status_dropdown.set_dnd.extra', defaultMessage: 'Disables all notifications'});
- }
-
- const tz = timezone || getBrowserTimezone();
- const currentTime = moment().tz(tz);
- const endTime = moment.unix(dndEndTime).tz(tz);
-
- let formattedEndTime;
-
- const diffDays = endTime.clone().startOf('day').diff(currentTime.clone().startOf('day'), 'days');
-
- switch (diffDays) {
- case 0:
- formattedEndTime = (
-
- );
- break;
- case 1:
- formattedEndTime = (
-
- );
- break;
- default:
- formattedEndTime = (
-
- );
- }
-
- return formattedEndTime;
- };
-
- render = (): JSX.Element => {
- const {intl} = this.props;
- const needsConfirm = this.isUserOutOfOffice() && this.props.autoResetPref === '';
- const {status, customStatus, isCustomStatusExpired, currentUser, timezone, dndEndTime} = this.props;
- const isStatusSet = customStatus && !isCustomStatusExpired && (customStatus.text?.length > 0 || customStatus.emoji?.length > 0);
-
- const setOnline = needsConfirm ? () => this.showStatusChangeConfirmation('online') : this.setOnline;
- const setDnd = needsConfirm ? () => this.showStatusChangeConfirmation('dnd') : this.setDnd;
- const setAway = needsConfirm ? () => this.showStatusChangeConfirmation('away') : this.setAway;
- const setOffline = needsConfirm ? () => this.showStatusChangeConfirmation('offline') : this.setOffline;
- const setCustomTimedDnd = needsConfirm ? () => this.showStatusChangeConfirmation('dnd') : this.setCustomTimedDnd;
-
- const selectedIndicator = (
-
- );
-
- const dndSubMenuItems = ([
- {
- id: 'dndSubMenu-header',
- direction: 'right',
- text: this.props.intl.formatMessage({id: 'status_dropdown.dnd_sub_menu_header', defaultMessage: 'Clear after:'}),
- isHeader: true,
- },
- ] as MenuType[])?.concat(
- this.dndTimes.map(({id, label}, index) => {
- let text: MenuType['text'] = this.props.intl.formatMessage(label);
- if (index === 4) {
- const tomorrow = getCurrentMomentForTimezone(this.props.timezone).add(1, 'day').set({hour: 9, minute: 0}).toDate();
- text = (
- <>
- {text}
-
-
- {', '}
-
-
- >
- );
- }
- return {
- id: `dndTime-${id}`,
- direction: 'right',
- text,
- action:
- index === 5 ? () => setCustomTimedDnd() : () => setDnd(index),
- };
- }),
- );
-
- const customStatusComponent = this.renderCustomStatus(isStatusSet);
-
- let menuAriaLabeltext;
- switch (this.props.status) {
- case UserStatuses.AWAY:
- menuAriaLabeltext = intl.formatMessage({
- id: 'status_dropdown.profile_button_label.away',
- defaultMessage: 'Current status: Away. Select to open profile and status menu.',
- });
- break;
- case UserStatuses.DND:
- menuAriaLabeltext = intl.formatMessage({
- id: 'status_dropdown.profile_button_label.dnd',
- defaultMessage: 'Current status: Do not disturb. Select to open profile and status menu.',
- });
- break;
- case UserStatuses.OFFLINE:
- menuAriaLabeltext = intl.formatMessage({
- id: 'status_dropdown.profile_button_label.offline',
- defaultMessage: 'Current status: Offline. Select to open profile and status menu.',
- });
- break;
- case UserStatuses.ONLINE:
- menuAriaLabeltext = intl.formatMessage({
- id: 'status_dropdown.profile_button_label.online',
- defaultMessage: 'Current status: Online. Select to open profile and status menu.',
- });
- break;
- case UserStatuses.OUT_OF_OFFICE:
- menuAriaLabeltext = intl.formatMessage({
- id: 'status_dropdown.profile_button_label.ooo',
- defaultMessage: 'Current status: Out of office. Select to open profile and status menu.',
- });
- break;
- default:
- menuAriaLabeltext = intl.formatMessage({
- id: 'status_dropdown.profile_button_label',
- defaultMessage: 'Select to open profile and status menu.',
- });
- }
-
- const dndExtraText = this.renderDndExtraText(dndEndTime, timezone);
-
- return (
-
-
-
-
- );
- };
-}
-export default injectIntl(StatusDropdown);
diff --git a/webapp/channels/src/components/user_account_menu/__snapshots__/user_account_name_menuitem.test.tsx.snap b/webapp/channels/src/components/user_account_menu/__snapshots__/user_account_name_menuitem.test.tsx.snap
new file mode 100644
index 0000000000..503dd88dda
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/__snapshots__/user_account_name_menuitem.test.tsx.snap
@@ -0,0 +1,32 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`UserAccountNameMenuItem should not break if no props are passed 1`] = `
+
+
+
+`;
diff --git a/webapp/channels/src/components/user_account_menu/index.ts b/webapp/channels/src/components/user_account_menu/index.ts
new file mode 100644
index 0000000000..08f321ae66
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/index.ts
@@ -0,0 +1,61 @@
+// 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 {bindActionCreators} from 'redux';
+import type {Dispatch} from 'redux';
+
+import {Client4} from 'mattermost-redux/client';
+import {Preferences} from 'mattermost-redux/constants';
+import {get} from 'mattermost-redux/selectors/entities/preferences';
+import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
+import {getCurrentUser, getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
+
+import {openModal} from 'actions/views/modals';
+import {makeGetCustomStatus, isCustomStatusExpired, isCustomStatusEnabled} from 'selectors/views/custom_status';
+
+import type {GlobalState} from 'types/store';
+
+import UserAccountMenu from './user_account_menu';
+
+function makeMapStateToProps() {
+ const getCustomStatus = makeGetCustomStatus();
+
+ return function mapStateToProps(state: GlobalState) {
+ const currentUser = getCurrentUser(state);
+ const userId = currentUser?.id;
+ const username = currentUser?.username;
+ const userFirstName = currentUser?.first_name;
+ const userLastName = currentUser?.last_name;
+ const customStatus = getCustomStatus(state, userId);
+
+ return {
+ userId,
+ username,
+ userFirstName,
+ userLastName,
+ profilePicture: Client4.getProfilePictureUrl(userId, currentUser?.last_picture_update),
+ autoResetPref: get(state, Preferences.CATEGORY_AUTO_RESET_MANUAL_STATUS, userId, ''),
+ status: getStatusForUserId(state, userId),
+ customStatus,
+ isCustomStatusExpired: isCustomStatusExpired(state, customStatus),
+ isCustomStatusEnabled: isCustomStatusEnabled(state),
+ timezone: getCurrentTimezone(state),
+ };
+ };
+}
+
+function mapDispatchToProps(dispatch: Dispatch) {
+ return {
+ actions: bindActionCreators({
+ openModal,
+ }, dispatch),
+ };
+}
+
+const connector = connect(makeMapStateToProps, mapDispatchToProps);
+
+export type PropsFromRedux = ConnectedProps;
+
+export default connector(UserAccountMenu);
diff --git a/webapp/channels/src/components/user_account_menu/user_account_away_menuitem.tsx b/webapp/channels/src/components/user_account_menu/user_account_away_menuitem.tsx
new file mode 100644
index 0000000000..0dab0ad7ba
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_away_menuitem.tsx
@@ -0,0 +1,80 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useMemo} from 'react';
+import {FormattedMessage} from 'react-intl';
+import {useDispatch} from 'react-redux';
+
+import {CheckIcon, ClockIcon} from '@mattermost/compass-icons/components';
+import type {UserProfile} from '@mattermost/types/users';
+
+import {setStatus} from 'mattermost-redux/actions/users';
+
+import {openModal} from 'actions/views/modals';
+
+import * as Menu from 'components/menu';
+import ResetStatusModal from 'components/reset_status_modal';
+
+import {ModalIdentifiers, UserStatuses} from 'utils/constants';
+
+interface Props {
+ userId: UserProfile['id'];
+ shouldConfirmBeforeStatusChange: boolean;
+ isStatusAway: boolean;
+}
+
+export default function UserAccountAwayMenuItem(props: Props) {
+ const dispatch = useDispatch();
+
+ function handleClick() {
+ if (props.shouldConfirmBeforeStatusChange) {
+ dispatch(openModal({
+ modalId: ModalIdentifiers.RESET_STATUS,
+ dialogType: ResetStatusModal,
+ dialogProps: {
+ newStatus: UserStatuses.AWAY,
+ },
+ }));
+ } else {
+ dispatch(setStatus({
+ user_id: props.userId,
+ status: UserStatuses.AWAY,
+ }));
+ }
+ }
+
+ const trailingElement = useMemo(() => {
+ if (props.isStatusAway) {
+ return (
+
+ );
+ }
+
+ return null;
+ }, [props.isStatusAway]);
+
+ return (
+
+ }
+ labels={
+
+ }
+ trailingElements={trailingElement}
+ aria-checked={props.isStatusAway}
+ onClick={handleClick}
+ />
+ );
+}
diff --git a/webapp/channels/src/components/user_account_menu/user_account_custom_status_menuitem.tsx b/webapp/channels/src/components/user_account_menu/user_account_custom_status_menuitem.tsx
new file mode 100644
index 0000000000..64cda51dc3
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_custom_status_menuitem.tsx
@@ -0,0 +1,140 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import classNames from 'classnames';
+import type {MouseEvent, TouchEvent, KeyboardEvent, ReactNode} from 'react';
+import React from 'react';
+import {FormattedMessage, useIntl} from 'react-intl';
+import {useDispatch, useSelector} from 'react-redux';
+
+import {PulsatingDot} from '@mattermost/components';
+import {type UserCustomStatus, CustomStatusDuration} from '@mattermost/types/users';
+
+import {unsetCustomStatus} from 'mattermost-redux/actions/users';
+
+import {showStatusDropdownPulsatingDot} from 'selectors/views/custom_status';
+
+import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
+import CustomStatusText from 'components/custom_status/custom_status_text';
+import ExpiryTime from 'components/custom_status/expiry_time';
+import * as Menu from 'components/menu';
+import WithTooltip from 'components/with_tooltip';
+
+interface Props {
+ timezone?: string;
+ customStatus?: UserCustomStatus;
+ openCustomStatusModal: (event: MouseEvent | KeyboardEvent) => void;
+}
+
+export default function UserAccountSetCustomStatusMenuItem(props: Props) {
+ const {formatMessage, formatTime, formatDate} = useIntl();
+ const dispatch = useDispatch();
+
+ const showPulsatingDot = useSelector(showStatusDropdownPulsatingDot);
+
+ function handleClear(event: MouseEvent | TouchEvent) {
+ event.stopPropagation();
+ dispatch(unsetCustomStatus());
+ }
+
+ const hasStatusWithText = Boolean(props.customStatus?.text?.length);
+ const hasStatusWithExpiry = props.customStatus?.duration !== CustomStatusDuration.DONT_CLEAR && props.customStatus?.expires_at;
+ const hasStatusWithNoExpiry = props.customStatus?.duration === CustomStatusDuration.DONT_CLEAR;
+
+ let label;
+ let ariaDescription: string;
+ if (hasStatusWithText && hasStatusWithExpiry) {
+ label = (
+ <>
+
+
+ >
+ );
+ ariaDescription = formatMessage({
+ id: 'userAccountMenu.setCustomStatusMenuItem.hasStatusWithTextAndExpiry.ariaDescription',
+ defaultMessage: 'Status is "{text}" and expires at {time}. Set a custom status.',
+ }, {
+ text: props.customStatus?.text,
+ time: `${formatTime(props.customStatus?.expires_at)} ${formatDate(props.customStatus?.expires_at)}`,
+ });
+ } else if (hasStatusWithText && hasStatusWithNoExpiry) {
+ label = (
+
+ );
+ ariaDescription = formatMessage({
+ id: 'userAccountMenu.setCustomStatusMenuItem.hasStatusWithTextAndNoExpiry.ariaDescription',
+ defaultMessage: 'Status is "{text}". Set a custom status.',
+ }, {
+ text: props.customStatus?.text,
+ });
+ } else if (!hasStatusWithText && hasStatusWithExpiry) {
+ label = (
+
+ );
+ ariaDescription = formatMessage({
+ id: 'userAccountMenu.setCustomStatusMenuItem.hasStatusWithExpiryAndNoText.ariaDescription',
+ defaultMessage: 'Status expires at {time}. Set a custom status.',
+ }, {
+ time: `${formatTime(props.customStatus?.expires_at)} ${formatDate(props.customStatus?.expires_at)}`,
+ });
+ } else {
+ label = (
+
+ );
+ ariaDescription = formatMessage({
+ id: 'userAccountMenu.setCustomStatusMenuItem.noStatusTextSet',
+ defaultMessage: 'Set custom status text',
+ });
+ }
+
+ let trailingElement: ReactNode | null = null;
+ if (showPulsatingDot) {
+ trailingElement = ;
+ } else if (hasStatusWithText || hasStatusWithExpiry || hasStatusWithNoExpiry) {
+ trailingElement = (
+
+
+
+ );
+ }
+
+ return (
+
+ }
+ labels={label}
+ trailingElements={trailingElement}
+ aria-description={ariaDescription}
+ onClick={props.openCustomStatusModal}
+ />
+ );
+}
diff --git a/webapp/channels/src/components/user_account_menu/user_account_dnd_menuitem.tsx b/webapp/channels/src/components/user_account_menu/user_account_dnd_menuitem.tsx
new file mode 100644
index 0000000000..269d9d6b47
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_dnd_menuitem.tsx
@@ -0,0 +1,305 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import moment from 'moment';
+import React, {useMemo} from 'react';
+import type {MouseEvent, KeyboardEvent} from 'react';
+import {FormattedDate, FormattedMessage, FormattedTime, useIntl} from 'react-intl';
+import {useDispatch, useSelector} from 'react-redux';
+
+import {CheckIcon, ChevronRightIcon, MinusCircleIcon} from '@mattermost/compass-icons/components';
+import type {UserProfile} from '@mattermost/types/users';
+
+import {setStatus} from 'mattermost-redux/actions/users';
+import {getBool} from 'mattermost-redux/selectors/entities/preferences';
+import {getDndEndTimeForUserId} from 'mattermost-redux/selectors/entities/users';
+
+import {openModal} from 'actions/views/modals';
+
+import DndCustomTimePicker from 'components/dnd_custom_time_picker_modal';
+import * as Menu from 'components/menu';
+import ResetStatusModal from 'components/reset_status_modal';
+
+import {ModalIdentifiers, Preferences, UserStatuses} from 'utils/constants';
+import {getCurrentMomentForTimezone, getBrowserTimezone, getCurrentDateTimeForTimezone} from 'utils/timezone';
+
+import type {GlobalState} from 'types/store';
+
+interface Props {
+ userId: UserProfile['id'];
+ timezone?: string;
+ shouldConfirmBeforeStatusChange: boolean;
+ isStatusDnd: boolean;
+}
+
+export default function UserAccountDndMenuItem(props: Props) {
+ const {formatMessage} = useIntl();
+ const dispatch = useDispatch();
+
+ const dndEndTime = useSelector((state: GlobalState) => getDndEndTimeForUserId(state, props.userId));
+
+ const isMilitaryTime = useSelector((state: GlobalState) => getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, false));
+
+ const tomorrow9AMDateObject = getCurrentMomentForTimezone(props.timezone).add(1, 'day').set({hour: 9, minute: 0}).toDate();
+
+ function openCustomTimePicker() {
+ if (props.shouldConfirmBeforeStatusChange) {
+ dispatch(openModal({
+ modalId: ModalIdentifiers.RESET_STATUS,
+ dialogType: ResetStatusModal,
+ dialogProps: {
+ newStatus: UserStatuses.DND,
+ },
+ }));
+ } else {
+ dispatch(openModal({
+ modalId: ModalIdentifiers.DND_CUSTOM_TIME_PICKER,
+ dialogType: DndCustomTimePicker,
+ dialogProps: {
+ currentDate: props.timezone ? getCurrentDateTimeForTimezone(props.timezone) : new Date(),
+ },
+ }));
+ }
+ }
+
+ function handleSubMenuItemClick(event: MouseEvent | KeyboardEvent) {
+ if (props.shouldConfirmBeforeStatusChange) {
+ dispatch(openModal({
+ modalId: ModalIdentifiers.RESET_STATUS,
+ dialogType: ResetStatusModal,
+ dialogProps: {
+ newStatus: UserStatuses.DND,
+ },
+ }));
+ return;
+ }
+
+ const {currentTarget: {id}} = event;
+
+ const currentDate = getCurrentMomentForTimezone(props.timezone);
+
+ let endTime = currentDate;
+ switch (id) {
+ case DND_SUB_MENU_ITEMS_IDS.DO_NOT_CLEAR:
+ endTime = moment(0);
+ break;
+ case DND_SUB_MENU_ITEMS_IDS.THIRTY_MINUTES:
+ // add 30 minutes in current time
+ endTime = currentDate.add(30, 'minutes');
+ break;
+ case DND_SUB_MENU_ITEMS_IDS.ONE_HOUR:
+ // add 1 hour in current time
+ endTime = currentDate.add(1, 'hour');
+ break;
+ case DND_SUB_MENU_ITEMS_IDS.TWO_HOURS:
+ // add 2 hours in current time
+ endTime = currentDate.add(2, 'hours');
+ break;
+ case DND_SUB_MENU_ITEMS_IDS.TOMORROW:
+ // set to next day 9 in the morning
+ endTime = currentDate.add(1, 'day').set({hour: 9, minute: 0});
+ break;
+ }
+
+ dispatch(setStatus({
+ user_id: props.userId,
+ status: UserStatuses.DND,
+ dnd_end_time: endTime.utc().unix(),
+ }));
+ }
+
+ const trailingElement = useMemo(() => {
+ if (props.isStatusDnd) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ return (
+
+ );
+ }, [props.isStatusDnd]);
+
+ // This function has dual purpose, first it returns the secondary label for the menu item and
+ // second it returns the part of the aria label for the menu item.
+ function getSecondaryLabel(isStatusDnd: boolean, dndEndTime?: number, timezone?: string) {
+ if (!isStatusDnd) {
+ return formatMessage({
+ id: 'userAccountMenu.dndMenuItem.secondaryLabel',
+ defaultMessage: 'Disables all notifications',
+ });
+ }
+
+ // When DND is set to not clear
+ if (!dndEndTime || dndEndTime === 0) {
+ return formatMessage({
+ id: 'userAccountMenu.dndMenuItem.secondaryLabel.doNotClear',
+ defaultMessage: 'Until indefinitely',
+ });
+ }
+
+ const tz = timezone || getBrowserTimezone();
+ const currentTime = moment().tz(tz);
+ const endTime = moment.unix(dndEndTime).tz(tz);
+
+ const diffDays = endTime.clone().startOf('day').diff(currentTime.clone().startOf('day'), 'days');
+
+ if (diffDays === 0) {
+ return formatMessage({
+ id: 'userAccountMenu.dndMenuItem.secondaryLabel.untilTodaySomeTime',
+ defaultMessage: 'Until {time}',
+ }, {
+ time: endTime.format('h:mm A'),
+ });
+ } else if (diffDays === 1) {
+ return formatMessage({
+ id: 'userAccountMenu.dndMenuItem.secondaryLabel.untilTomorrowSomeTime',
+ defaultMessage: 'Until tomorrow {time}',
+ }, {
+ time: endTime.format('h:mm A'),
+ });
+ }
+
+ return formatMessage({
+ id: 'userAccountMenu.dndMenuItem.secondaryLabel.untilLaterSomeTime',
+ defaultMessage: 'Until {time}',
+ }, {
+ time: endTime.format('lll'),
+ });
+ }
+
+ return (
+
+ }
+ labels={
+ <>
+
+
+ {getSecondaryLabel(props.isStatusDnd, dndEndTime, props.timezone)}
+
+ >
+ }
+ role='menuitemradio' // Prevents menu item from closing, not a recommended solution
+ aria-checked={props.isStatusDnd}
+ trailingElements={trailingElement}
+ >
+
+
+ }
+ onClick={handleSubMenuItemClick}
+ />
+
+ }
+ onClick={handleSubMenuItemClick}
+ />
+
+ }
+ onClick={handleSubMenuItemClick}
+ />
+
+ }
+ onClick={handleSubMenuItemClick}
+ />
+
+ }
+ trailingElements={
+
+ ),
+ shortTime: (
+
+ ),
+ }}
+ />
+ }
+ onClick={handleSubMenuItemClick}
+ />
+
+ }
+ onClick={openCustomTimePicker}
+ />
+
+ );
+}
+
+const DND_SUB_MENU_ITEMS_IDS = {
+ DO_NOT_CLEAR: 'userAccountMenu.dndSubMenuItem.doNotClear',
+ THIRTY_MINUTES: 'userAccountMenu.dndSubMenuItem.30Minutes',
+ ONE_HOUR: 'userAccountMenu.dndSubMenuItem.1Hour',
+ TWO_HOURS: 'userAccountMenu.dndSubMenuItem.2Hours',
+ TOMORROW: 'userAccountMenu.dndSubMenuItem.tomorrow',
+};
diff --git a/webapp/channels/src/components/user_account_menu/user_account_logout_menuitem.tsx b/webapp/channels/src/components/user_account_menu/user_account_logout_menuitem.tsx
new file mode 100644
index 0000000000..a37813d3c9
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_logout_menuitem.tsx
@@ -0,0 +1,35 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+
+import {ExitToAppIcon} from '@mattermost/compass-icons/components';
+
+import {emitUserLoggedOutEvent} from 'actions/global_actions';
+
+import * as Menu from 'components/menu';
+
+export default function UserAccountLogoutMenuItem() {
+ function handleClick() {
+ emitUserLoggedOutEvent();
+ }
+
+ return (
+
+ }
+ labels={
+
+ }
+ onClick={handleClick}
+ />
+ );
+}
diff --git a/webapp/channels/src/components/user_account_menu/user_account_menu.scss b/webapp/channels/src/components/user_account_menu/user_account_menu.scss
new file mode 100644
index 0000000000..a6b20ce0da
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_menu.scss
@@ -0,0 +1,172 @@
+@import "utils/_mixins";
+
+#userAccountMenu {
+ .userAccountMenu_nameMenuItem {
+ align-items: center;
+
+ .leading-element {
+ width: unset;
+ height: unset;
+ margin-inline-end: 12px;
+ }
+
+ .label-elements {
+ gap: 0;
+ }
+
+ span.userAccountMenu_nameMenuItem_primaryLabel {
+ max-width: 150px;
+ margin: 0;
+ font-family: 'Metropolis', sans-serif;
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 22px;
+ @include textEllipsis;
+ }
+
+ span.userAccountMenu_nameMenuItem_secondaryLabel {
+ max-width: 150px;
+ font-size: 14px;
+ line-height: 18px;
+ @include textEllipsis;
+ }
+ }
+
+ .userAccountMenu_setCustomStatusMenuItem {
+ .userAccountMenu_setCustomStatusMenuItem_icon > svg {
+ fill: rgba(var(--center-channel-color-rgb), 0.75);
+ }
+
+ // This aligns the icon in the center, emoji icon is wrapped in a span
+ // thats why we need to align the span as well
+ > .leading-element, .userAccountMenu_setCustomStatusMenuItem_icon {
+ display: flex;
+ align-items: center;
+ }
+
+ .label-elements > span {
+ color: rgba(var(--center-channel-color-rgb), 0.75);
+ }
+ }
+
+ .userAccountMenu_customStatusMenuItem {
+ &.hasTrailingElement {
+ .label-elements {
+ > span {
+ max-width: 156px;
+ }
+ }
+ }
+
+ .label-elements {
+ > span {
+ max-width: 196px;
+ @include textEllipsis;
+ }
+ }
+ }
+
+ .userAccountMenu_menuItemTrailingCheckIcon {
+ color: var(--button-bg);
+ font-size: 16px;
+ }
+
+ .userAccountMenu_menuItemTrailingIconClear {
+ font-size: 16px;
+ }
+
+ .userAccountMenu_profileMenuItem_tourWrapper {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ }
+}
+
+.userAccountMenu_dndMenuItem_subMenuTitle {
+ padding: 6px 20px;
+ margin: 0;
+ color: var(--center-channel-color);
+ font-weight: bold;
+ pointer-events: none;
+}
+
+.userAccountMenu_menuButton {
+ position: relative;
+ display: flex;
+ height: auto;
+ align-items: center;
+ align-self: center;
+ padding: 4px 8px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ margin-inline-start: 8px;
+
+ &:hover, &[aria-expanded="true"] {
+ background: var(--sidebar-text-08);
+
+ .userStatusIconWrapper {
+ background-image: linear-gradient(
+ 0deg,
+ rgba(var(--global-header-text-rgb), 0.08),
+ rgba(var(--global-header-text-rgb), 0.08)
+ );
+ }
+ }
+
+ &.withCustomStatus {
+ background: rgba(var(--global-header-text-rgb), 0.16);
+
+ .userStatusIconWrapper {
+ background-image: linear-gradient(
+ 0deg,
+ rgba(var(--global-header-text-rgb), 0.16),
+ rgba(var(--global-header-text-rgb), 0.16)
+ );
+ }
+ }
+
+ .userStatusIconWrapper {
+ position: absolute;
+ top: auto;
+ right: 3px;
+ bottom: 1px;
+ display: inline-flex;
+ width: 16px;
+ height: 16px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 100px;
+ margin: 0;
+ background-color: var(--global-header-background);
+ color: var(--global-header-text);
+ line-height: 0;
+ visibility: visible;
+
+ &:hover {
+ cursor: pointer;
+ }
+
+ svg {
+ top: 0;
+ }
+ }
+}
+
+.userAccountMenu_onlineMenuItem_icon {
+ color: var(--online-indicator);
+}
+
+.userAccountMenu_awayMenuItem_icon {
+ color: var(--away-indicator);
+}
+
+.userAccountMenu_dndMenuItem_icon {
+ color: var(--dnd-indicator);
+}
+
+.userAccountMenu_offlineMenuItem_icon {
+ color: rgba(141, 147, 165, 0.56);
+}
diff --git a/webapp/channels/src/components/user_account_menu/user_account_menu.tsx b/webapp/channels/src/components/user_account_menu/user_account_menu.tsx
new file mode 100644
index 0000000000..629c7eb340
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_menu.tsx
@@ -0,0 +1,133 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import classNames from 'classnames';
+import React from 'react';
+import type {MouseEvent, KeyboardEvent} from 'react';
+import {useIntl} from 'react-intl';
+
+import CustomStatusModal from 'components/custom_status/custom_status_modal';
+import * as Menu from 'components/menu';
+
+import {ModalIdentifiers, UserStatuses} from 'utils/constants';
+
+import UserAccountAwayMenuItem from './user_account_away_menuitem';
+import UserAccountCustomStatusMenuItem from './user_account_custom_status_menuitem';
+import UserAccountDndMenuItem from './user_account_dnd_menuitem';
+import UserAccountLogoutMenuItem from './user_account_logout_menuitem';
+import UserAccountMenuButton from './user_account_menuButton';
+import UserAccountNameMenuItem from './user_account_name_menuitem';
+import UserAccountOfflineMenuItem from './user_account_offline_menuitem';
+import UserAccountOnlineMenuItem from './user_account_online_menuitem';
+import UserAccountOutOfOfficeMenuItem from './user_account_out_of_office_menuitem';
+import UserAccountProfileMenuItem from './user_account_profile_menuitem';
+import UserAccountSetCustomStatusMenuItem from './user_account_set_custom_status_menuitem';
+
+import type {PropsFromRedux} from './index';
+
+import './user_account_menu.scss';
+
+type Props = PropsFromRedux;
+
+export default function UserAccountMenu(props: Props) {
+ const {formatMessage} = useIntl();
+
+ function openCustomStatusModal(event: MouseEvent | KeyboardEvent) {
+ event.stopPropagation();
+ props.actions.openModal({
+ modalId: ModalIdentifiers.CUSTOM_STATUS,
+ dialogType: CustomStatusModal,
+ });
+ }
+
+ const isCustomStatusSet = !props.isCustomStatusExpired && props.customStatus && (props.customStatus.text?.length > 0 || props.customStatus.emoji?.length > 0);
+ const shouldConfirmBeforeStatusChange = props.autoResetPref === '' && props.status === UserStatuses.OUT_OF_OFFICE;
+
+ return (
+
+ ),
+ }}
+ menu={{
+ id: 'userAccountMenu',
+ width: '264px',
+ }}
+ anchorOrigin={{
+ vertical: 'bottom',
+ horizontal: 'right',
+ }}
+ transformOrigin={{
+ vertical: 'top',
+ horizontal: 'right',
+ }}
+ >
+
+
+ {props.status === UserStatuses.OUT_OF_OFFICE && (
+
+ )}
+ {props.status === UserStatuses.OUT_OF_OFFICE && (
+
+ )}
+ {props.isCustomStatusEnabled && !isCustomStatusSet && (
+
+ )}
+ {props.isCustomStatusEnabled && isCustomStatusSet && (
+
+ )}
+ {props.isCustomStatusEnabled && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/webapp/channels/src/components/user_account_menu/user_account_menuButton.tsx b/webapp/channels/src/components/user_account_menu/user_account_menuButton.tsx
new file mode 100644
index 0000000000..434f8fd52e
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_menuButton.tsx
@@ -0,0 +1,139 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useMemo} from 'react';
+import type {MouseEvent, KeyboardEvent} from 'react';
+import {defineMessages, useIntl} from 'react-intl';
+
+import {CheckCircleIcon, ClockIcon, MinusCircleIcon, RadioboxBlankIcon} from '@mattermost/compass-icons/components';
+
+import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
+import Avatar from 'components/widgets/users/avatar/avatar';
+
+import {UserStatuses} from 'utils/constants';
+
+interface Props {
+ profilePicture?: string;
+ openCustomStatusModal: ((event: MouseEvent | KeyboardEvent) => void);
+ status?: string;
+}
+
+export default function UserAccountMenuButton({
+ profilePicture,
+ openCustomStatusModal,
+ status,
+}: Props) {
+ const {formatMessage} = useIntl();
+
+ const statusIcon = useMemo(() => {
+ if (status && status === UserStatuses.ONLINE) {
+ return (
+
+ );
+ }
+
+ if (status && status === UserStatuses.AWAY) {
+ return (
+
+ );
+ }
+
+ if (status && status === UserStatuses.DND) {
+ return (
+
+ );
+ }
+
+ // Defaults to offline
+ return (
+
+ );
+ }, [status]);
+
+ return (
+ <>
+
+
+
+ {statusIcon}
+
+
+ >
+ );
+}
+
+const ariaDescriptionsDefineMessages = defineMessages({
+ outOfOffice: {
+ id: 'userAccountMenu.menuButton.ariaDescription.ooo',
+ defaultMessage: 'Status is "Out of office".',
+ },
+ online: {
+ id: 'userAccountMenu.menuButton.ariaDescription.online',
+ defaultMessage: 'Status is "Online".',
+ },
+ away: {
+ id: 'userAccountMenu.menuButton.ariaDescription.away',
+ defaultMessage: 'Status is "Away".',
+ },
+ dnd: {
+ id: 'userAccountMenu.menuButton.ariaDescription.dnd',
+ defaultMessage: 'Status is "Do not disturb".',
+ },
+ offline: {
+ id: 'userAccountMenu.menuButton.ariaDescription.offline',
+ defaultMessage: 'Status is "Offline".',
+ },
+});
+
+function getMenuButtonAriaDescription(status?: string) {
+ let ariaLabel;
+ switch (status) {
+ case UserStatuses.OUT_OF_OFFICE:
+ ariaLabel = ariaDescriptionsDefineMessages.outOfOffice;
+ break;
+ case UserStatuses.ONLINE:
+ ariaLabel = ariaDescriptionsDefineMessages.online;
+ break;
+ case UserStatuses.AWAY:
+ ariaLabel = ariaDescriptionsDefineMessages.away;
+ break;
+ case UserStatuses.DND:
+ ariaLabel = ariaDescriptionsDefineMessages.dnd;
+ break;
+ case UserStatuses.OFFLINE:
+ default:
+ ariaLabel = ariaDescriptionsDefineMessages.offline;
+ break;
+ }
+
+ return ariaLabel;
+}
diff --git a/webapp/channels/src/components/user_account_menu/user_account_name_menuitem.test.tsx b/webapp/channels/src/components/user_account_menu/user_account_name_menuitem.test.tsx
new file mode 100644
index 0000000000..de3a554594
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_name_menuitem.test.tsx
@@ -0,0 +1,92 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import * as reactRedux from 'react-redux';
+
+import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
+import {TestHelper} from 'utils/test_helper';
+
+import type {GlobalState} from 'types/store';
+
+import UserAccountNameMenuItem from './user_account_name_menuitem';
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux') as typeof import('react-redux'),
+ useDispatch: jest.fn(),
+}));
+
+jest.mock('actions/views/modals', () => ({
+ openModal: jest.fn(),
+}));
+
+describe('UserAccountNameMenuItem', () => {
+ const user = TestHelper.getUserMock({
+ first_name: 'sampleFirstName',
+ last_name: 'sampleLastName',
+ username: 'sampleUsername',
+ });
+
+ let initialState: GlobalState;
+
+ beforeEach(() => {
+ initialState = {
+ entities: {
+ users: {
+ profiles: {
+ [user.id]: user,
+ },
+ currentUserId: user.id,
+ },
+ },
+ } as GlobalState;
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ test('should render with both first and last name along with username', () => {
+ renderWithContext(, initialState);
+
+ expect(screen.getByText('sampleFirstName sampleLastName')).toBeInTheDocument();
+ expect(screen.getByText('@sampleUsername')).toBeInTheDocument();
+ });
+
+ test('should render with only username', () => {
+ const state = {
+ entities: {
+ users: {
+ profiles: {
+ [user.id]: {
+ id: user.id,
+ username: user.username,
+ },
+ },
+ currentUserId: user.id,
+ },
+ },
+ } as GlobalState;
+
+ renderWithContext(, state);
+
+ expect(screen.queryByText('sampleFirstName sampleLastName')).not.toBeInTheDocument();
+ expect(screen.getByText('@sampleUsername')).toBeInTheDocument();
+ });
+
+ test('should try to open user settings modal', async () => {
+ jest.spyOn(reactRedux, 'useDispatch').mockReturnValue(jest.fn());
+
+ renderWithContext(, initialState);
+
+ userEvent.click(screen.getByRole('menuitem'));
+
+ expect(reactRedux.useDispatch).toHaveBeenCalledTimes(1);
+ });
+
+ test('should not break if no props are passed', () => {
+ const {container} = renderWithContext();
+
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/webapp/channels/src/components/user_account_menu/user_account_name_menuitem.tsx b/webapp/channels/src/components/user_account_menu/user_account_name_menuitem.tsx
new file mode 100644
index 0000000000..b7bd61da1a
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_name_menuitem.tsx
@@ -0,0 +1,78 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {useDispatch, useSelector} from 'react-redux';
+
+import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
+
+import {openModal} from 'actions/views/modals';
+
+import * as Menu from 'components/menu';
+import UserSettingsModal from 'components/user_settings/modal';
+import Avatar from 'components/widgets/users/avatar/avatar';
+
+import {ModalIdentifiers} from 'utils/constants';
+
+interface Props extends Menu.FirstMenuItemProps {
+ profilePicture?: string;
+}
+
+export default function UserAccountNameMenuItem({profilePicture, ...rest}: Props) {
+ const dispatch = useDispatch();
+
+ const currentUser = useSelector(getCurrentUser);
+
+ function handleClick() {
+ dispatch(openModal({
+ modalId: ModalIdentifiers.USER_SETTINGS,
+ dialogType: UserSettingsModal,
+ dialogProps: {isContentProductSettings: false},
+ }));
+ }
+
+ function getLabel() {
+ if (
+ currentUser?.first_name?.length > 0 ||
+ currentUser?.last_name?.length > 0
+ ) {
+ const name = `${currentUser?.first_name} ${currentUser?.last_name}`?.trim();
+
+ return (
+ <>
+
+ {name}
+
+
+ {'@' + currentUser?.username}
+
+ >
+ );
+ }
+
+ const username = `@${currentUser?.username}`?.trim();
+
+ return (
+
+ {username}
+
+ );
+ }
+
+ return (
+
+ }
+ labels={getLabel()}
+ onClick={handleClick}
+ aria-haspopup={true}
+ {...rest}
+ />
+ );
+}
diff --git a/webapp/channels/src/components/user_account_menu/user_account_offline_menuitem.tsx b/webapp/channels/src/components/user_account_menu/user_account_offline_menuitem.tsx
new file mode 100644
index 0000000000..ed49b1528c
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_offline_menuitem.tsx
@@ -0,0 +1,80 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useMemo} from 'react';
+import {FormattedMessage} from 'react-intl';
+import {useDispatch} from 'react-redux';
+
+import {CheckIcon, RadioboxBlankIcon} from '@mattermost/compass-icons/components';
+import type {UserProfile} from '@mattermost/types/users';
+
+import {setStatus} from 'mattermost-redux/actions/users';
+
+import {openModal} from 'actions/views/modals';
+
+import * as Menu from 'components/menu';
+import ResetStatusModal from 'components/reset_status_modal';
+
+import {ModalIdentifiers, UserStatuses} from 'utils/constants';
+
+interface Props {
+ userId: UserProfile['id'];
+ shouldConfirmBeforeStatusChange: boolean;
+ isStatusOffline: boolean;
+}
+
+export default function UserAccountOfflineMenuItem(props: Props) {
+ const dispatch = useDispatch();
+
+ function handleClick() {
+ if (props.shouldConfirmBeforeStatusChange) {
+ dispatch(openModal({
+ modalId: ModalIdentifiers.RESET_STATUS,
+ dialogType: ResetStatusModal,
+ dialogProps: {
+ newStatus: UserStatuses.OFFLINE,
+ },
+ }));
+ } else {
+ dispatch(setStatus({
+ user_id: props.userId,
+ status: UserStatuses.OFFLINE,
+ }));
+ }
+ }
+
+ const trailingElement = useMemo(() => {
+ if (props.isStatusOffline) {
+ return (
+
+ );
+ }
+
+ return null;
+ }, [props.isStatusOffline]);
+
+ return (
+
+ }
+ labels={
+
+ }
+ trailingElements={trailingElement}
+ aria-checked={props.isStatusOffline}
+ onClick={handleClick}
+ />
+ );
+}
diff --git a/webapp/channels/src/components/user_account_menu/user_account_online_menuitem.tsx b/webapp/channels/src/components/user_account_menu/user_account_online_menuitem.tsx
new file mode 100644
index 0000000000..0e1a0102b4
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_online_menuitem.tsx
@@ -0,0 +1,80 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useMemo} from 'react';
+import {FormattedMessage} from 'react-intl';
+import {useDispatch} from 'react-redux';
+
+import {CheckCircleIcon, CheckIcon} from '@mattermost/compass-icons/components';
+import type {UserProfile} from '@mattermost/types/users';
+
+import {setStatus} from 'mattermost-redux/actions/users';
+
+import {openModal} from 'actions/views/modals';
+
+import * as Menu from 'components/menu';
+import ResetStatusModal from 'components/reset_status_modal';
+
+import {UserStatuses, ModalIdentifiers} from 'utils/constants';
+
+interface Props {
+ userId: UserProfile['id'];
+ shouldConfirmBeforeStatusChange: boolean;
+ isStatusOnline: boolean;
+}
+
+export default function UserAccountOnlineMenuItem(props: Props) {
+ const dispatch = useDispatch();
+
+ function handleClick() {
+ if (props.shouldConfirmBeforeStatusChange) {
+ dispatch(openModal({
+ modalId: ModalIdentifiers.RESET_STATUS,
+ dialogType: ResetStatusModal,
+ dialogProps: {
+ newStatus: UserStatuses.ONLINE,
+ },
+ }));
+ } else {
+ dispatch(setStatus({
+ user_id: props.userId,
+ status: UserStatuses.ONLINE,
+ }));
+ }
+ }
+
+ const trailingElement = useMemo(() => {
+ if (props.isStatusOnline) {
+ return (
+
+ );
+ }
+
+ return null;
+ }, [props.isStatusOnline]);
+
+ return (
+
+ }
+ labels={
+
+ }
+ trailingElements={trailingElement}
+ aria-checked={props.isStatusOnline}
+ onClick={handleClick}
+ />
+ );
+}
diff --git a/webapp/channels/src/components/user_account_menu/user_account_out_of_office_menuitem.test.tsx b/webapp/channels/src/components/user_account_menu/user_account_out_of_office_menuitem.test.tsx
new file mode 100644
index 0000000000..8cb35ce178
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_out_of_office_menuitem.test.tsx
@@ -0,0 +1,57 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+import * as modalActions from 'actions/views/modals';
+
+import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
+import {WindowSizes} from 'utils/constants';
+import {TestHelper} from 'utils/test_helper';
+
+import UserAccountOutOfOfficeMenuItem, {type Props} from './user_account_out_of_office_menuitem';
+
+describe('UserAccountOutOfOfficeMenuItem', () => {
+ let defaultProps: Props;
+
+ beforeEach(() => {
+ defaultProps = {
+ userId: TestHelper.getUserMock().id,
+ shouldConfirmBeforeStatusChange: false,
+ };
+ });
+
+ test('should not render if status is not out of office', () => {
+ const props = {...defaultProps, isStatusOutOfOffice: false};
+ renderWithContext();
+
+ expect(screen.queryByText('Out of office')).not.toBeInTheDocument();
+ });
+
+ test('should only render when status is out of office', () => {
+ renderWithContext();
+
+ expect(screen.getAllByText(/Out of office/).length).toBe(1);
+ expect(screen.getAllByText(/Automatic replies are enabled/).length).toBe(1);
+ });
+
+ test('should open reset status modal when confirming before status change option is true', async () => {
+ jest.spyOn(modalActions, 'openModal');
+
+ // Work around since in mobile menu's onClick get executed immediately and not so on non-mobile
+ // see handleClick func of webapp/channels/src/components/menu/menu_item.tsx
+ const initialState = {
+ views: {
+ browser: {
+ windowSize: WindowSizes.MOBILE_VIEW,
+ },
+ },
+ };
+ const props = {...defaultProps, shouldConfirmBeforeStatusChange: true};
+ renderWithContext(, initialState);
+
+ userEvent.click(screen.getByRole('menuitem'));
+
+ expect(modalActions.openModal).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/webapp/channels/src/components/user_account_menu/user_account_out_of_office_menuitem.tsx b/webapp/channels/src/components/user_account_menu/user_account_out_of_office_menuitem.tsx
new file mode 100644
index 0000000000..5c67b27c0f
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_out_of_office_menuitem.tsx
@@ -0,0 +1,76 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+import {useDispatch} from 'react-redux';
+
+import {CancelIcon, CheckIcon} from '@mattermost/compass-icons/components';
+import type {UserProfile} from '@mattermost/types/users';
+
+import {setStatus} from 'mattermost-redux/actions/users';
+
+import {openModal} from 'actions/views/modals';
+
+import * as Menu from 'components/menu';
+import ResetStatusModal from 'components/reset_status_modal';
+
+import {UserStatuses, ModalIdentifiers} from 'utils/constants';
+
+export interface Props {
+ userId: UserProfile['id'];
+ shouldConfirmBeforeStatusChange: boolean;
+}
+
+export default function UserAccountOutOfOfficeMenuItem(props: Props) {
+ const dispatch = useDispatch();
+
+ function handleClick() {
+ if (props.shouldConfirmBeforeStatusChange) {
+ dispatch(openModal({
+ modalId: ModalIdentifiers.RESET_STATUS,
+ dialogType: ResetStatusModal,
+ dialogProps: {
+ newStatus: UserStatuses.ONLINE,
+ },
+ }));
+ } else {
+ dispatch(setStatus({
+ user_id: props.userId,
+ status: UserStatuses.ONLINE,
+ }));
+ }
+ }
+
+ return (
+
+ }
+ labels={
+ <>
+
+
+ >
+ }
+ trailingElements={
+
+ }
+ aria-checked='true'
+ onClick={handleClick}
+ />
+ );
+}
diff --git a/webapp/channels/src/components/user_account_menu/user_account_profile_menuitem.tsx b/webapp/channels/src/components/user_account_menu/user_account_profile_menuitem.tsx
new file mode 100644
index 0000000000..a1857686e2
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_profile_menuitem.tsx
@@ -0,0 +1,80 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+import {useDispatch, useSelector} from 'react-redux';
+
+import {AccountOutlineIcon} from '@mattermost/compass-icons/components';
+import type {UserProfile} from '@mattermost/types/users';
+
+import {savePreferences} from 'mattermost-redux/actions/preferences';
+import {getInt} from 'mattermost-redux/selectors/entities/preferences';
+
+import {openModal} from 'actions/views/modals';
+
+import * as Menu from 'components/menu';
+import {OnboardingTaskCategory, OnboardingTasksName, TaskNameMapToSteps, CompleteYourProfileTour} from 'components/onboarding_tasks';
+import UserSettingsModal from 'components/user_settings/modal';
+
+import {ModalIdentifiers} from 'utils/constants';
+
+import type {GlobalState} from 'types/store';
+
+interface Props {
+ userId: UserProfile['id'];
+}
+
+export default function UserAccountProfileMenuItem(props: Props) {
+ const dispatch = useDispatch();
+
+ const onboardingTaskStep = useSelector((state: GlobalState) => getInt(state, OnboardingTaskCategory, OnboardingTasksName.COMPLETE_YOUR_PROFILE, 0));
+ const isCompleteYourProfileTaskPending = onboardingTaskStep === TaskNameMapToSteps[OnboardingTasksName.COMPLETE_YOUR_PROFILE].STARTED;
+
+ function handleClick() {
+ dispatch(openModal({
+ modalId: ModalIdentifiers.USER_SETTINGS,
+ dialogType: UserSettingsModal,
+ dialogProps: {isContentProductSettings: false},
+ }));
+ }
+
+ function handleTourClick() {
+ const taskName = OnboardingTasksName.COMPLETE_YOUR_PROFILE;
+ const steps = TaskNameMapToSteps[taskName];
+
+ dispatch(savePreferences(props.userId, [{
+ user_id: props.userId,
+ category: OnboardingTaskCategory,
+ name: taskName,
+ value: steps.FINISHED.toString(),
+ }]));
+ }
+
+ return (
+
+ }
+ labels={
+
+ }
+ trailingElements={isCompleteYourProfileTaskPending && (
+
+
+
+ )}
+ aria-haspopup={true}
+ onClick={handleClick}
+ />
+ );
+}
diff --git a/webapp/channels/src/components/user_account_menu/user_account_set_custom_status_menuitem.tsx b/webapp/channels/src/components/user_account_menu/user_account_set_custom_status_menuitem.tsx
new file mode 100644
index 0000000000..ee9ce7006d
--- /dev/null
+++ b/webapp/channels/src/components/user_account_menu/user_account_set_custom_status_menuitem.tsx
@@ -0,0 +1,35 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type {MouseEvent, KeyboardEvent} from 'react';
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+
+import * as Menu from 'components/menu';
+import EmojiIcon from 'components/widgets/icons/emoji_icon';
+
+interface Props {
+ openCustomStatusModal: (event: MouseEvent | KeyboardEvent) => void;
+}
+
+export default function UserAccountSetCustomStatusMenuItem(props: Props) {
+ return (
+
+ }
+ labels={
+
+ }
+ aria-haspopup={true}
+ onClick={props.openCustomStatusModal}
+ />
+ );
+}
diff --git a/webapp/channels/src/components/view_user_group_modal/view_user_group_list_item/view_user_group_list_item.tsx b/webapp/channels/src/components/view_user_group_modal/view_user_group_list_item/view_user_group_list_item.tsx
index 48c297fe83..86f2604518 100644
--- a/webapp/channels/src/components/view_user_group_modal/view_user_group_list_item/view_user_group_list_item.tsx
+++ b/webapp/channels/src/components/view_user_group_modal/view_user_group_list_item/view_user_group_list_item.tsx
@@ -55,7 +55,6 @@ const ViewUserGroupListItem = (props: Props) => {
className='group-member-row'
>
-
{
- it('should return the isOpen value from the state', async () => {
- const store = await configureStore();
- expect(isStatusDropdownOpen(store.getState())).toBeFalsy();
- });
-
- it('should return true if statusDropdown in explicitly opened', async () => {
- const store = await configureStore();
- await store.dispatch(setStatusDropdown(true));
- expect(isStatusDropdownOpen(store.getState())).toBeTruthy();
- });
-});
diff --git a/webapp/channels/src/selectors/views/status_dropdown.ts b/webapp/channels/src/selectors/views/status_dropdown.ts
deleted file mode 100644
index fdb5b517c2..0000000000
--- a/webapp/channels/src/selectors/views/status_dropdown.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-import type {GlobalState} from 'types/store';
-
-export function isStatusDropdownOpen(state: GlobalState) {
- return state.views.statusDropdown.isOpen;
-}
diff --git a/webapp/channels/src/types/store/views.ts b/webapp/channels/src/types/store/views.ts
index 8a2c5dc9fe..570393bfcd 100644
--- a/webapp/channels/src/types/store/views.ts
+++ b/webapp/channels/src/types/store/views.ts
@@ -194,10 +194,6 @@ export type ViewsState = {
lastSelectedChannel: string;
};
- statusDropdown: {
- isOpen: boolean;
- };
-
addChannelDropdown: {
isOpen: boolean;
};
diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx
index fccb34fc69..7a6b917795 100644
--- a/webapp/channels/src/utils/constants.tsx
+++ b/webapp/channels/src/utils/constants.tsx
@@ -250,7 +250,6 @@ export const ActionTypes = keyMirror({
INCREMENT_EMOJI_PICKER_PAGE: null,
SET_RECENT_SKIN: null,
- STATUS_DROPDOWN_TOGGLE: null,
ADD_CHANNEL_DROPDOWN_TOGGLE: null,
ADD_CHANNEL_CTA_DROPDOWN_TOGGLE: null,
diff --git a/webapp/channels/src/utils/keyboard.ts b/webapp/channels/src/utils/keyboard.ts
index 4a978d44db..86fec74fcf 100644
--- a/webapp/channels/src/utils/keyboard.ts
+++ b/webapp/channels/src/utils/keyboard.ts
@@ -33,3 +33,20 @@ export function isKeyPressed(event: React.KeyboardEvent | KeyboardEvent, key: [s
// used for different language keyboards to detect the position of keys
return event.keyCode === key[1];
}
+
+export const MenuButtonIds = {
+ userAccountMenu: 'userAccountMenuButton',
+} as const;
+
+/**
+ * Since the menu component requires actual interaction with the button
+ * of the menu for opening the menus, we trigger it from here
+ */
+export function clickOnMenuButton(buttonId: string) {
+ const menuButton = document.getElementById(buttonId);
+ if (!menuButton) {
+ return;
+ }
+
+ menuButton.click();
+}
diff --git a/webapp/platform/components/src/pulsating_dot/pulsating_dot.scss b/webapp/platform/components/src/pulsating_dot/pulsating_dot.scss
index addc3fb045..1095389d03 100644
--- a/webapp/platform/components/src/pulsating_dot/pulsating_dot.scss
+++ b/webapp/platform/components/src/pulsating_dot/pulsating_dot.scss
@@ -35,3 +35,37 @@
animation: pulse2 2s ease 0s infinite;
}
}
+
+@keyframes pulse1 {
+ 0% {
+ opacity: 1;
+ transform: scale(1);
+ }
+
+ 80% {
+ opacity: 0;
+ transform: scale(2.5);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: scale(2.5);
+ }
+}
+
+@keyframes pulse2 {
+ 0% {
+ opacity: 1;
+ transform: scale(1);
+ }
+
+ 30% {
+ opacity: 1;
+ transform: scale(1);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: scale(2.5);
+ }
+}